diff --git a/.agents/skills/configuring-horizon/SKILL.md b/.agents/skills/configuring-horizon/SKILL.md new file mode 100644 index 000000000..bed1e74c0 --- /dev/null +++ b/.agents/skills/configuring-horizon/SKILL.md @@ -0,0 +1,85 @@ +--- +name: configuring-horizon +description: "Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching." +license: MIT +metadata: + author: laravel +--- + +# Horizon Configuration + +## Documentation + +Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment. + +For deeper guidance on specific topics, read the relevant reference file before implementing: + +- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling +- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config +- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs +- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config + +## Basic Usage + +### Installation + +```bash +php artisan horizon:install +``` + +### Supervisor Configuration + +Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block: + + +```php +'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], +], + +'environments' => [ + 'production' => [ + 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3], + ], + 'local' => [ + 'supervisor-1' => ['maxProcesses' => 2], + ], +], +``` + +### Dashboard Authorization + +Restrict access in `App\Providers\HorizonServiceProvider`: + + +```php +protected function gate(): void +{ + Gate::define('viewHorizon', function (User $user) { + return $user->is_admin; + }); +} +``` + +## Verification + +1. Run `php artisan horizon` and visit `/horizon` +2. Confirm dashboard access is restricted as expected +3. Check that metrics populate after scheduling `horizon:snapshot` + +## Common Pitfalls + +- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported. +- Redis Cluster is not supported. Horizon requires a standalone Redis connection. +- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration. +- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it. +- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out. +- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics. +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. \ No newline at end of file diff --git a/.agents/skills/configuring-horizon/references/metrics.md b/.agents/skills/configuring-horizon/references/metrics.md new file mode 100644 index 000000000..312f79ee7 --- /dev/null +++ b/.agents/skills/configuring-horizon/references/metrics.md @@ -0,0 +1,21 @@ +# Metrics & Snapshots + +## Where to Find It + +Search with `search-docs`: +- `"horizon metrics snapshot"` for the snapshot command and scheduling +- `"horizon trim snapshots"` for retention configuration + +## What to Watch For + +### Metrics dashboard stays blank until `horizon:snapshot` is scheduled + +Running `horizon` artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every 5 minutes via Laravel's scheduler. + +### Register the snapshot in the scheduler rather than running it manually + +A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the exact scheduler registration syntax, which differs between Laravel 10 and 11+. + +### `metrics.trim_snapshots` is a snapshot count, not a time duration + +The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage. \ No newline at end of file diff --git a/.agents/skills/configuring-horizon/references/notifications.md b/.agents/skills/configuring-horizon/references/notifications.md new file mode 100644 index 000000000..943d1a26a --- /dev/null +++ b/.agents/skills/configuring-horizon/references/notifications.md @@ -0,0 +1,21 @@ +# Notifications & Alerts + +## Where to Find It + +Search with `search-docs`: +- `"horizon notifications"` for Horizon's built-in notification routing helpers +- `"horizon long wait detected"` for LongWaitDetected event details + +## What to Watch For + +### `waits` in `config/horizon.php` controls the LongWaitDetected threshold + +The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration. + +### Use Horizon's built-in notification routing in `HorizonServiceProvider` + +Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration. + +### Failed job alerts are separate from Horizon's documented notification routing + +Horizon's 12.x documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API. \ No newline at end of file diff --git a/.agents/skills/configuring-horizon/references/supervisors.md b/.agents/skills/configuring-horizon/references/supervisors.md new file mode 100644 index 000000000..9da0c1769 --- /dev/null +++ b/.agents/skills/configuring-horizon/references/supervisors.md @@ -0,0 +1,27 @@ +# Supervisor & Balancing Configuration + +## Where to Find It + +Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions: +- `"horizon supervisor configuration"` for the full options list +- `"horizon balancing strategies"` for auto, simple, and false modes +- `"horizon autoscaling workers"` for autoScalingStrategy details +- `"horizon environment configuration"` for the defaults and environments merge + +## What to Watch For + +### The `environments` array merges into `defaults` rather than replacing it + +The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`. + +### Use separate named supervisors to enforce queue priority + +Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this. + +### Use `balance: false` to keep a fixed number of workers on a dedicated queue + +Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable. + +### Set `balanceCooldown` to prevent rapid worker scaling under bursty load + +When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle. \ No newline at end of file diff --git a/.agents/skills/configuring-horizon/references/tags.md b/.agents/skills/configuring-horizon/references/tags.md new file mode 100644 index 000000000..263c955c1 --- /dev/null +++ b/.agents/skills/configuring-horizon/references/tags.md @@ -0,0 +1,21 @@ +# Tags & Silencing + +## Where to Find It + +Search with `search-docs`: +- `"horizon tags"` for the tagging API and auto-tagging behaviour +- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options + +## What to Watch For + +### Eloquent model jobs are tagged automatically without any extra code + +If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed. + +### `silenced` hides jobs from the dashboard completed list but does not stop them from running + +Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs. + +### `silenced_tags` hides all jobs carrying a matching tag from the completed list + +Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes. \ No newline at end of file diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/fortify-development/SKILL.md similarity index 72% rename from .agents/skills/developing-with-fortify/SKILL.md rename to .agents/skills/fortify-development/SKILL.md index 2ff71a4b4..86322d9c0 100644 --- a/.agents/skills/developing-with-fortify/SKILL.md +++ b/.agents/skills/fortify-development/SKILL.md @@ -1,6 +1,9 @@ --- -name: developing-with-fortify -description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +name: fortify-development +description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' +license: MIT +metadata: + author: laravel --- # Laravel Fortify Development @@ -39,7 +42,7 @@ ### Two-Factor Authentication Setup ``` - [ ] Add TwoFactorAuthenticatable trait to User model - [ ] Enable feature in config/fortify.php -- [ ] Run migrations for 2FA columns +- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate - [ ] Set up view callbacks in FortifyServiceProvider - [ ] Create 2FA management UI - [ ] Test QR code and recovery codes @@ -75,14 +78,26 @@ ### SPA Authentication Setup ``` - [ ] Set 'views' => false in config/fortify.php -- [ ] Install and configure Laravel Sanctum -- [ ] Use 'web' guard in fortify config +- [ ] Install and configure Laravel Sanctum for session-based SPA authentication +- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication) - [ ] Set up CSRF token handling - [ ] Test XHR authentication flows ``` > Use `search-docs` for integration and SPA authentication patterns. +#### Two-Factor Authentication in SPA Mode + +When `views` is set to `false`, Fortify returns JSON responses instead of redirects. + +If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required: + +```json +{ + "two_factor": true +} +``` + ## Best Practices ### Custom Authentication Logic diff --git a/.agents/skills/laravel-actions/SKILL.md b/.agents/skills/laravel-actions/SKILL.md new file mode 100644 index 000000000..862dd55b5 --- /dev/null +++ b/.agents/skills/laravel-actions/SKILL.md @@ -0,0 +1,302 @@ +--- +name: laravel-actions +description: Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring. +--- + +# Laravel Actions or `lorisleiva/laravel-actions` + +## Overview + +Use this skill to implement or update actions based on `lorisleiva/laravel-actions` with consistent structure and predictable testing patterns. + +## Quick Workflow + +1. Confirm the package is installed with `composer show lorisleiva/laravel-actions`. +2. Create or edit an action class that uses `Lorisleiva\Actions\Concerns\AsAction`. +3. Implement `handle(...)` with the core business logic first. +4. Add adapter methods only when needed for the requested entrypoint: + - `asController` (+ route/invokable controller usage) + - `asJob` (+ dispatch) + - `asListener` (+ event listener wiring) + - `asCommand` (+ command signature/description) +5. Add or update tests for the chosen entrypoint. +6. When tests need isolation, use action fakes (`MyAction::fake()`) and assertions (`MyAction::assertDispatched()`). + +## Base Action Pattern + +Use this minimal skeleton and expand only what is needed. + +```php +handle($id)`. +- Call with dependency injection: `app(PublishArticle::class)->handle($id)`. + +### Run as Controller + +- Use route to class (invokable style), e.g. `Route::post('/articles/{id}/publish', PublishArticle::class)`. +- Add `asController(...)` for HTTP-specific adaptation and return a response. +- Add request validation (`rules()` or custom validator hooks) when input comes from HTTP. + +### Run as Job + +- Dispatch with `PublishArticle::dispatch($id)`. +- Use `asJob(...)` only for queue-specific behavior; keep domain logic in `handle(...)`. +- In this project, job Actions often define additional queue lifecycle methods and job properties for retries, uniqueness, and timing control. + +#### Project Pattern: Job Action with Extra Methods + +```php +addMinutes(30); + } + + public function getJobBackoff(): array + { + return [60, 120]; + } + + public function getJobUniqueId(Demo $demo): string + { + return $demo->id; + } + + public function handle(Demo $demo): void + { + // Core business logic. + } + + public function asJob(JobDecorator $job, Demo $demo): void + { + // Queue-specific orchestration and retry behavior. + $this->handle($demo); + } +} +``` + +Use these members only when needed: + +- `$jobTries`: max attempts for the queued execution. +- `$jobMaxExceptions`: max unhandled exceptions before failing. +- `getJobRetryUntil()`: absolute retry deadline. +- `getJobBackoff()`: retry delay strategy per attempt. +- `getJobUniqueId(...)`: deduplication key for unique jobs. +- `asJob(JobDecorator $job, ...)`: access attempt metadata and queue-only branching. + +### Run as Listener + +- Register the action class as listener in `EventServiceProvider`. +- Use `asListener(EventName $event)` and delegate to `handle(...)`. + +### Run as Command + +- Define `$commandSignature` and `$commandDescription` properties. +- Implement `asCommand(Command $command)` and keep console IO in this method only. +- Import `Command` with `use Illuminate\Console\Command;`. + +## Testing Guidance + +Use a two-layer strategy: + +1. `handle(...)` tests for business correctness. +2. entrypoint tests (`asController`, `asJob`, `asListener`, `asCommand`) for wiring/orchestration. + +### Deep Dive: `AsFake` methods (2.x) + +Reference: https://www.laravelactions.com/2.x/as-fake.html + +Use these methods intentionally based on what you want to prove. + +#### `mock()` + +- Replaces the action with a full mock. +- Best when you need strict expectations and argument assertions. + +```php +PublishArticle::mock() + ->shouldReceive('handle') + ->once() + ->with(42) + ->andReturnTrue(); +``` + +#### `partialMock()` + +- Replaces the action with a partial mock. +- Best when you want to keep most real behavior but stub one expensive/internal method. + +```php +PublishArticle::partialMock() + ->shouldReceive('fetchRemoteData') + ->once() + ->andReturn(['ok' => true]); +``` + +#### `spy()` + +- Replaces the action with a spy. +- Best for post-execution verification ("was called with X") without predefining all expectations. + +```php +$spy = PublishArticle::spy()->allows('handle')->andReturnTrue(); + +// execute code that triggers the action... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +#### `shouldRun()` + +- Shortcut for `mock()->shouldReceive('handle')`. +- Best for compact orchestration assertions. + +```php +PublishArticle::shouldRun()->once()->with(42)->andReturnTrue(); +``` + +#### `shouldNotRun()` + +- Shortcut for `mock()->shouldNotReceive('handle')`. +- Best for guard-clause tests and branch coverage. + +```php +PublishArticle::shouldNotRun(); +``` + +#### `allowToRun()` + +- Shortcut for spy + allowing `handle`. +- Best when you want execution to proceed but still assert interaction. + +```php +$spy = PublishArticle::allowToRun()->andReturnTrue(); +// ... +$spy->shouldHaveReceived('handle')->once(); +``` + +#### `isFake()` and `clearFake()` + +- `isFake()` checks whether the class is currently swapped. +- `clearFake()` resets the fake and prevents cross-test leakage. + +```php +expect(PublishArticle::isFake())->toBeFalse(); +PublishArticle::mock(); +expect(PublishArticle::isFake())->toBeTrue(); +PublishArticle::clearFake(); +expect(PublishArticle::isFake())->toBeFalse(); +``` + +### Recommended test matrix for Actions + +- Business rule test: call `handle(...)` directly with real dependencies/factories. +- HTTP wiring test: hit route/controller, fake downstream actions with `shouldRun` or `shouldNotRun`. +- Job wiring test: dispatch action as job, assert expected downstream action calls. +- Event listener test: dispatch event, assert action interaction via fake/spy. +- Console test: run artisan command, assert action invocation and output. + +### Practical defaults + +- Prefer `shouldRun()` and `shouldNotRun()` for readability in branch tests. +- Prefer `spy()`/`allowToRun()` when behavior is mostly real and you only need call verification. +- Prefer `mock()` when interaction contracts are strict and should fail fast. +- Use `clearFake()` in cleanup when a fake might leak into another test. +- Keep side effects isolated: fake only the action under test boundary, not everything. + +### Pest style examples + +```php +it('dispatches the downstream action', function () { + SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0); + + FinalizeInvoice::run(123); +}); + +it('does not dispatch when invoice is already sent', function () { + SendInvoiceEmail::shouldNotRun(); + + FinalizeInvoice::run(123, alreadySent: true); +}); +``` + +Run the minimum relevant suite first, e.g. `php artisan test --compact --filter=PublishArticle` or by specific test file. + +## Troubleshooting Checklist + +- Ensure the class uses `AsAction` and namespace matches autoload. +- Check route registration when used as controller. +- Check queue config when using `dispatch`. +- Verify event-to-listener mapping in `EventServiceProvider`. +- Keep transport concerns in adapter methods (`asController`, `asCommand`, etc.), not in `handle(...)`. + +## Common Pitfalls + +- Putting HTTP response/redirect logic inside `handle(...)` instead of `asController(...)`. +- Duplicating business rules across `as*` methods rather than delegating to `handle(...)`. +- Assuming listener wiring works without explicit registration where required. +- Testing only entrypoints and skipping direct `handle(...)` behavior tests. +- Overusing Actions for one-off, single-context logic with no reuse pressure. + +## Topic References + +Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused on workflow and decision rules. + +- Object entrypoint: `references/object.md` +- Controller entrypoint: `references/controller.md` +- Job entrypoint: `references/job.md` +- Listener entrypoint: `references/listener.md` +- Command entrypoint: `references/command.md` +- With attributes: `references/with-attributes.md` +- Testing and fakes: `references/testing-fakes.md` +- Troubleshooting: `references/troubleshooting.md` \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/command.md b/.agents/skills/laravel-actions/references/command.md new file mode 100644 index 000000000..a7b255daf --- /dev/null +++ b/.agents/skills/laravel-actions/references/command.md @@ -0,0 +1,160 @@ +# Command Entrypoint (`asCommand`) + +## Scope + +Use this reference when exposing actions as Artisan commands. + +## Recap + +- Documents command execution via `asCommand(...)` and fallback to `handle(...)`. +- Covers command metadata via methods/properties (signature, description, help, hidden). +- Includes registration example and focused artisan test pattern. +- Reinforces separation between console I/O and domain logic. + +## Recommended pattern + +- Define `$commandSignature` and `$commandDescription`. +- Implement `asCommand(Command $command)` for console I/O. +- Keep business logic in `handle(...)`. + +## Methods used (`CommandDecorator`) + +### `asCommand` + +Called when executed as a command. If missing, it falls back to `handle(...)`. + +```php +use Illuminate\Console\Command; + +class UpdateUserRole +{ + use AsAction; + + public string $commandSignature = 'users:update-role {user_id} {role}'; + + public function handle(User $user, string $newRole): void + { + $user->update(['role' => $newRole]); + } + + public function asCommand(Command $command): void + { + $this->handle( + User::findOrFail($command->argument('user_id')), + $command->argument('role') + ); + + $command->info('Done!'); + } +} +``` + +### `getCommandSignature` + +Defines the command signature. Required when registering an action as a command if no `$commandSignature` property is set. + +```php +public function getCommandSignature(): string +{ + return 'users:update-role {user_id} {role}'; +} +``` + +### `$commandSignature` + +Property alternative to `getCommandSignature`. + +```php +public string $commandSignature = 'users:update-role {user_id} {role}'; +``` + +### `getCommandDescription` + +Provides command description. + +```php +public function getCommandDescription(): string +{ + return 'Updates the role of a given user.'; +} +``` + +### `$commandDescription` + +Property alternative to `getCommandDescription`. + +```php +public string $commandDescription = 'Updates the role of a given user.'; +``` + +### `getCommandHelp` + +Provides additional help text shown with `--help`. + +```php +public function getCommandHelp(): string +{ + return 'My help message.'; +} +``` + +### `$commandHelp` + +Property alternative to `getCommandHelp`. + +```php +public string $commandHelp = 'My help message.'; +``` + +### `isCommandHidden` + +Defines whether command should be hidden from artisan list. Default is `false`. + +```php +public function isCommandHidden(): bool +{ + return true; +} +``` + +### `$commandHidden` + +Property alternative to `isCommandHidden`. + +```php +public bool $commandHidden = true; +``` + +## Examples + +### Register in console kernel + +```php +// app/Console/Kernel.php +protected $commands = [ + UpdateUserRole::class, +]; +``` + +### Focused command test + +```php +$this->artisan('users:update-role 1 admin') + ->expectsOutput('Done!') + ->assertSuccessful(); +``` + +## Checklist + +- `use Illuminate\Console\Command;` is imported. +- Signature/options/arguments are documented. +- Command test verifies invocation and output. + +## Common pitfalls + +- Mixing command I/O with domain logic in `handle(...)`. +- Missing/ambiguous command signature. + +## References + +- https://www.laravelactions.com/2.x/as-command.html \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/controller.md b/.agents/skills/laravel-actions/references/controller.md new file mode 100644 index 000000000..d48c34df8 --- /dev/null +++ b/.agents/skills/laravel-actions/references/controller.md @@ -0,0 +1,339 @@ +# Controller Entrypoint (`asController`) + +## Scope + +Use this reference when exposing an action through HTTP routes. + +## Recap + +- Documents controller lifecycle around `asController(...)` and response adapters. +- Covers routing patterns, middleware, and optional in-action `routes()` registration. +- Summarizes validation/authorization hooks used by `ActionRequest`. +- Provides extension points for JSON/HTML responses and failure customization. + +## Recommended pattern + +- Route directly to action class when appropriate. +- Keep HTTP adaptation in controller methods (`asController`, `jsonResponse`, `htmlResponse`). +- Keep domain logic in `handle(...)`. + +## Methods provided (`AsController` trait) + +### `__invoke` + +Required so Laravel can register the action class as an invokable controller. + +```php +$action($someArguments); + +// Equivalent to: +$action->handle($someArguments); +``` + +If the method does not exist, Laravel route registration fails for invokable controllers. + +```php +// Illuminate\Routing\RouteAction +protected static function makeInvokable($action) +{ + if (! method_exists($action, '__invoke')) { + throw new UnexpectedValueException("Invalid route action: [{$action}]."); + } + + return $action.'@__invoke'; +} +``` + +If you need your own `__invoke`, alias the trait implementation: + +```php +class MyAction +{ + use AsAction { + __invoke as protected invokeFromLaravelActions; + } + + public function __invoke() + { + // Custom behavior... + } +} +``` + +## Methods used (`ControllerDecorator` + `ActionRequest`) + +### `asController` + +Called when used as invokable controller. If missing, it falls back to `handle(...)`. + +```php +public function asController(User $user, Request $request): Response +{ + $article = $this->handle( + $user, + $request->get('title'), + $request->get('body') + ); + + return redirect()->route('articles.show', [$article]); +} +``` + +### `jsonResponse` + +Called after `asController` when request expects JSON. + +```php +public function jsonResponse(Article $article, Request $request): ArticleResource +{ + return new ArticleResource($article); +} +``` + +### `htmlResponse` + +Called after `asController` when request expects HTML. + +```php +public function htmlResponse(Article $article, Request $request): Response +{ + return redirect()->route('articles.show', [$article]); +} +``` + +### `getControllerMiddleware` + +Adds middleware directly on the action controller. + +```php +public function getControllerMiddleware(): array +{ + return ['auth', MyCustomMiddleware::class]; +} +``` + +### `routes` + +Defines routes directly in the action. + +```php +public static function routes(Router $router) +{ + $router->get('author/{author}/articles', static::class); +} +``` + +To enable this, register routes from actions in a service provider: + +```php +use Lorisleiva\Actions\Facades\Actions; + +Actions::registerRoutes(); +Actions::registerRoutes('app/MyCustomActionsFolder'); +Actions::registerRoutes([ + 'app/Authentication', + 'app/Billing', + 'app/TeamManagement', +]); +``` + +### `prepareForValidation` + +Called before authorization and validation are resolved. + +```php +public function prepareForValidation(ActionRequest $request): void +{ + $request->merge(['some' => 'additional data']); +} +``` + +### `authorize` + +Defines authorization logic. + +```php +public function authorize(ActionRequest $request): bool +{ + return $request->user()->role === 'author'; +} +``` + +You can also return gate responses: + +```php +use Illuminate\Auth\Access\Response; + +public function authorize(ActionRequest $request): Response +{ + if ($request->user()->role !== 'author') { + return Response::deny('You must be an author to create a new article.'); + } + + return Response::allow(); +} +``` + +### `rules` + +Defines validation rules. + +```php +public function rules(): array +{ + return [ + 'title' => ['required', 'min:8'], + 'body' => ['required', IsValidMarkdown::class], + ]; +} +``` + +### `withValidator` + +Adds custom validation logic with an after hook. + +```php +use Illuminate\Validation\Validator; + +public function withValidator(Validator $validator, ActionRequest $request): void +{ + $validator->after(function (Validator $validator) use ($request) { + if (! Hash::check($request->get('current_password'), $request->user()->password)) { + $validator->errors()->add('current_password', 'Wrong password.'); + } + }); +} +``` + +### `afterValidator` + +Alternative to add post-validation checks. + +```php +use Illuminate\Validation\Validator; + +public function afterValidator(Validator $validator, ActionRequest $request): void +{ + if (! Hash::check($request->get('current_password'), $request->user()->password)) { + $validator->errors()->add('current_password', 'Wrong password.'); + } +} +``` + +### `getValidator` + +Provides a custom validator instead of default rules pipeline. + +```php +use Illuminate\Validation\Factory; +use Illuminate\Validation\Validator; + +public function getValidator(Factory $factory, ActionRequest $request): Validator +{ + return $factory->make($request->only('title', 'body'), [ + 'title' => ['required', 'min:8'], + 'body' => ['required', IsValidMarkdown::class], + ]); +} +``` + +### `getValidationData` + +Defines which data is validated (default: `$request->all()`). + +```php +public function getValidationData(ActionRequest $request): array +{ + return $request->all(); +} +``` + +### `getValidationMessages` + +Custom validation error messages. + +```php +public function getValidationMessages(): array +{ + return [ + 'title.required' => 'Looks like you forgot the title.', + 'body.required' => 'Is that really all you have to say?', + ]; +} +``` + +### `getValidationAttributes` + +Human-friendly names for request attributes. + +```php +public function getValidationAttributes(): array +{ + return [ + 'title' => 'headline', + 'body' => 'content', + ]; +} +``` + +### `getValidationRedirect` + +Custom redirect URL on validation failure. + +```php +public function getValidationRedirect(UrlGenerator $url): string +{ + return $url->to('/my-custom-redirect-url'); +} +``` + +### `getValidationErrorBag` + +Custom error bag name on validation failure (default: `default`). + +```php +public function getValidationErrorBag(): string +{ + return 'my_custom_error_bag'; +} +``` + +### `getValidationFailure` + +Override validation failure behavior. + +```php +public function getValidationFailure(): void +{ + throw new MyCustomValidationException(); +} +``` + +### `getAuthorizationFailure` + +Override authorization failure behavior. + +```php +public function getAuthorizationFailure(): void +{ + throw new MyCustomAuthorizationException(); +} +``` + +## Checklist + +- Route wiring points to the action class. +- `asController(...)` delegates to `handle(...)`. +- Validation/authorization methods are explicit where needed. +- Response mapping is split by channel (`jsonResponse`, `htmlResponse`) when useful. +- HTTP tests cover both success and validation/authorization failure branches. + +## Common pitfalls + +- Putting response/redirect logic in `handle(...)`. +- Duplicating business rules in `asController(...)` instead of delegating. +- Assuming action route discovery works without `Actions::registerRoutes(...)` when using in-action `routes()`. + +## References + +- https://www.laravelactions.com/2.x/as-controller.html \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/job.md b/.agents/skills/laravel-actions/references/job.md new file mode 100644 index 000000000..b4c7cbea0 --- /dev/null +++ b/.agents/skills/laravel-actions/references/job.md @@ -0,0 +1,425 @@ +# Job Entrypoint (`dispatch`, `asJob`) + +## Scope + +Use this reference when running an action through queues. + +## Recap + +- Lists async/sync dispatch helpers and conditional dispatch variants. +- Covers job wrapping/chaining with `makeJob`, `makeUniqueJob`, and `withChain`. +- Documents queue assertion helpers for tests (`assertPushed*`). +- Summarizes `JobDecorator` hooks/properties for retries, uniqueness, timeout, and failure handling. + +## Recommended pattern + +- Dispatch with `Action::dispatch(...)` for async execution. +- Keep queue-specific orchestration in `asJob(...)`. +- Keep reusable business logic in `handle(...)`. + +## Methods provided (`AsJob` trait) + +### `dispatch` + +Dispatches the action asynchronously. + +```php +SendTeamReportEmail::dispatch($team); +``` + +### `dispatchIf` + +Dispatches asynchronously only if condition is met. + +```php +SendTeamReportEmail::dispatchIf($team->plan === 'premium', $team); +``` + +### `dispatchUnless` + +Dispatches asynchronously unless condition is met. + +```php +SendTeamReportEmail::dispatchUnless($team->plan === 'free', $team); +``` + +### `dispatchSync` + +Dispatches synchronously. + +```php +SendTeamReportEmail::dispatchSync($team); +``` + +### `dispatchNow` + +Alias of `dispatchSync`. + +```php +SendTeamReportEmail::dispatchNow($team); +``` + +### `dispatchAfterResponse` + +Dispatches synchronously after the HTTP response is sent. + +```php +SendTeamReportEmail::dispatchAfterResponse($team); +``` + +### `makeJob` + +Creates a `JobDecorator` wrapper. Useful with `dispatch(...)` helper or chains. + +```php +dispatch(SendTeamReportEmail::makeJob($team)); +``` + +### `makeUniqueJob` + +Creates a `UniqueJobDecorator` wrapper. Usually automatic with `ShouldBeUnique`, but can be forced. + +```php +dispatch(SendTeamReportEmail::makeUniqueJob($team)); +``` + +### `withChain` + +Attaches jobs to run after successful processing. + +```php +$chain = [ + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +]; + +CreateNewTeamReport::withChain($chain)->dispatch($team); +``` + +Equivalent using `Bus::chain(...)`: + +```php +use Illuminate\Support\Facades\Bus; + +Bus::chain([ + CreateNewTeamReport::makeJob($team), + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +])->dispatch(); +``` + +Chain assertion example: + +```php +use Illuminate\Support\Facades\Bus; + +Bus::fake(); + +Bus::assertChained([ + CreateNewTeamReport::makeJob($team), + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +]); +``` + +### `assertPushed` + +Asserts the action was queued. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertPushed(); +SendTeamReportEmail::assertPushed(3); +SendTeamReportEmail::assertPushed($callback); +SendTeamReportEmail::assertPushed(3, $callback); +``` + +`$callback` receives: +- Action instance. +- Dispatched arguments. +- `JobDecorator` instance. +- Queue name. + +### `assertNotPushed` + +Asserts the action was not queued. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertNotPushed(); +SendTeamReportEmail::assertNotPushed($callback); +``` + +### `assertPushedOn` + +Asserts the action was queued on a specific queue. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertPushedOn('reports'); +SendTeamReportEmail::assertPushedOn('reports', 3); +SendTeamReportEmail::assertPushedOn('reports', $callback); +SendTeamReportEmail::assertPushedOn('reports', 3, $callback); +``` + +## Methods used (`JobDecorator`) + +### `asJob` + +Called when dispatched as a job. Falls back to `handle(...)` if missing. + +```php +class SendTeamReportEmail +{ + use AsAction; + + public function handle(Team $team, bool $fullReport = false): void + { + // Prepare report and send it to all $team->users. + } + + public function asJob(Team $team): void + { + $this->handle($team, true); + } +} +``` + +### `getJobMiddleware` + +Adds middleware to the queued action. + +```php +public function getJobMiddleware(array $parameters): array +{ + return [new RateLimited('reports')]; +} +``` + +### `configureJob` + +Configures `JobDecorator` options. + +```php +use Lorisleiva\Actions\Decorators\JobDecorator; + +public function configureJob(JobDecorator $job): void +{ + $job->onConnection('my_connection') + ->onQueue('my_queue') + ->through(['my_middleware']) + ->chain(['my_chain']) + ->delay(60); +} +``` + +### `$jobConnection` + +Defines queue connection. + +```php +public string $jobConnection = 'my_connection'; +``` + +### `$jobQueue` + +Defines queue name. + +```php +public string $jobQueue = 'my_queue'; +``` + +### `$jobTries` + +Defines max attempts. + +```php +public int $jobTries = 10; +``` + +### `$jobMaxExceptions` + +Defines max unhandled exceptions before failure. + +```php +public int $jobMaxExceptions = 3; +``` + +### `$jobBackoff` + +Defines retry delay seconds. + +```php +public int $jobBackoff = 60; +``` + +### `getJobBackoff` + +Defines retry delay (int or per-attempt array). + +```php +public function getJobBackoff(): int +{ + return 60; +} + +public function getJobBackoff(): array +{ + return [30, 60, 120]; +} +``` + +### `$jobTimeout` + +Defines timeout in seconds. + +```php +public int $jobTimeout = 60 * 30; +``` + +### `$jobRetryUntil` + +Defines timestamp retry deadline. + +```php +public int $jobRetryUntil = 1610191764; +``` + +### `getJobRetryUntil` + +Defines retry deadline as `DateTime`. + +```php +public function getJobRetryUntil(): DateTime +{ + return now()->addMinutes(30); +} +``` + +### `getJobDisplayName` + +Customizes queued job display name. + +```php +public function getJobDisplayName(): string +{ + return 'Send team report email'; +} +``` + +### `getJobTags` + +Adds queue tags. + +```php +public function getJobTags(Team $team): array +{ + return ['report', 'team:'.$team->id]; +} +``` + +### `getJobUniqueId` + +Defines uniqueness key when using `ShouldBeUnique`. + +```php +public function getJobUniqueId(Team $team): int +{ + return $team->id; +} +``` + +### `$jobUniqueId` + +Static uniqueness key alternative. + +```php +public string $jobUniqueId = 'some_static_key'; +``` + +### `getJobUniqueFor` + +Defines uniqueness lock duration in seconds. + +```php +public function getJobUniqueFor(Team $team): int +{ + return $team->role === 'premium' ? 1800 : 3600; +} +``` + +### `$jobUniqueFor` + +Property alternative for uniqueness lock duration. + +```php +public int $jobUniqueFor = 3600; +``` + +### `getJobUniqueVia` + +Defines cache driver used for uniqueness lock. + +```php +public function getJobUniqueVia() +{ + return Cache::driver('redis'); +} +``` + +### `$jobDeleteWhenMissingModels` + +Property alternative for missing model handling. + +```php +public bool $jobDeleteWhenMissingModels = true; +``` + +### `getJobDeleteWhenMissingModels` + +Defines whether jobs with missing models are deleted. + +```php +public function getJobDeleteWhenMissingModels(): bool +{ + return true; +} +``` + +### `jobFailed` + +Handles job failure. Receives exception and dispatched parameters. + +```php +public function jobFailed(?Throwable $e, ...$parameters): void +{ + // Notify users, report errors, trigger compensations... +} +``` + +## Checklist + +- Async/sync dispatch method matches use-case (`dispatch`, `dispatchSync`, `dispatchAfterResponse`). +- Queue config is explicit when needed (`$jobConnection`, `$jobQueue`, `configureJob`). +- Retry/backoff/timeout policies are intentional. +- `asJob(...)` delegates to `handle(...)` unless queue-specific branching is required. +- Queue tests use `Queue::fake()` and action assertions (`assertPushed*`). + +## Common pitfalls + +- Embedding domain logic only in `asJob(...)`. +- Forgetting uniqueness/timeout/retry controls on heavy jobs. +- Missing queue-specific assertions in tests. + +## References + +- https://www.laravelactions.com/2.x/as-job.html \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/listener.md b/.agents/skills/laravel-actions/references/listener.md new file mode 100644 index 000000000..c5233001d --- /dev/null +++ b/.agents/skills/laravel-actions/references/listener.md @@ -0,0 +1,81 @@ +# Listener Entrypoint (`asListener`) + +## Scope + +Use this reference when wiring actions to domain/application events. + +## Recap + +- Shows how listener execution maps event payloads into `handle(...)` arguments. +- Describes `asListener(...)` fallback behavior and adaptation role. +- Includes event registration example for provider wiring. +- Emphasizes test focus on dispatch and action interaction. + +## Recommended pattern + +- Register action listener in `EventServiceProvider` (or project equivalent). +- Use `asListener(Event $event)` for event adaptation. +- Delegate core logic to `handle(...)`. + +## Methods used (`ListenerDecorator`) + +### `asListener` + +Called when executed as an event listener. If missing, it falls back to `handle(...)`. + +```php +class SendOfferToNearbyDrivers +{ + use AsAction; + + public function handle(Address $source, Address $destination): void + { + // ... + } + + public function asListener(TaxiRequested $event): void + { + $this->handle($event->source, $event->destination); + } +} +``` + +## Examples + +### Event registration + +```php +// app/Providers/EventServiceProvider.php +protected $listen = [ + TaxiRequested::class => [ + SendOfferToNearbyDrivers::class, + ], +]; +``` + +### Focused listener test + +```php +use Illuminate\Support\Facades\Event; + +Event::fake(); + +TaxiRequested::dispatch($source, $destination); + +Event::assertDispatched(TaxiRequested::class); +``` + +## Checklist + +- Event-to-listener mapping is registered. +- Listener method signature matches event contract. +- Listener tests verify dispatch and action interaction. + +## Common pitfalls + +- Assuming automatic listener registration when explicit mapping is required. +- Re-implementing business logic in `asListener(...)`. + +## References + +- https://www.laravelactions.com/2.x/as-listener.html \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/object.md b/.agents/skills/laravel-actions/references/object.md new file mode 100644 index 000000000..6a90be4d5 --- /dev/null +++ b/.agents/skills/laravel-actions/references/object.md @@ -0,0 +1,118 @@ +# Object Entrypoint (`run`, `make`, DI) + +## Scope + +Use this reference when the action is invoked as a plain object. + +## Recap + +- Explains object-style invocation with `make`, `run`, `runIf`, `runUnless`. +- Clarifies when to use static helpers versus DI/manual invocation. +- Includes minimal examples for direct run and service-level injection. +- Highlights boundaries: business logic stays in `handle(...)`. + +## Recommended pattern + +- Keep core business logic in `handle(...)`. +- Prefer `Action::run(...)` for readability. +- Use `Action::make()->handle(...)` or DI only when needed. + +## Methods provided + +### `make` + +Resolves the action from the container. + +```php +PublishArticle::make(); + +// Equivalent to: +app(PublishArticle::class); +``` + +### `run` + +Resolves and executes the action. + +```php +PublishArticle::run($articleId); + +// Equivalent to: +PublishArticle::make()->handle($articleId); +``` + +### `runIf` + +Resolves and executes the action only if the condition is met. + +```php +PublishArticle::runIf($shouldPublish, $articleId); + +// Equivalent mental model: +if ($shouldPublish) { + PublishArticle::run($articleId); +} +``` + +### `runUnless` + +Resolves and executes the action only if the condition is not met. + +```php +PublishArticle::runUnless($alreadyPublished, $articleId); + +// Equivalent mental model: +if (! $alreadyPublished) { + PublishArticle::run($articleId); +} +``` + +## Checklist + +- Input/output types are explicit. +- `handle(...)` has no transport concerns. +- Business behavior is covered by direct `handle(...)` tests. + +## Common pitfalls + +- Putting HTTP/CLI/queue concerns in `handle(...)`. +- Calling adapters from `handle(...)` instead of the reverse. + +## References + +- https://www.laravelactions.com/2.x/as-object.html + +## Examples + +### Minimal object-style invocation + +```php +final class PublishArticle +{ + use AsAction; + + public function handle(int $articleId): bool + { + // Domain logic... + return true; + } +} + +$published = PublishArticle::run(42); +``` + +### Dependency injection invocation + +```php +final class ArticleService +{ + public function __construct( + private PublishArticle $publishArticle + ) {} + + public function publish(int $articleId): bool + { + return $this->publishArticle->handle($articleId); + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/testing-fakes.md b/.agents/skills/laravel-actions/references/testing-fakes.md new file mode 100644 index 000000000..97766e6ce --- /dev/null +++ b/.agents/skills/laravel-actions/references/testing-fakes.md @@ -0,0 +1,160 @@ +# Testing and Action Fakes + +## Scope + +Use this reference when isolating action orchestration in tests. + +## Recap + +- Summarizes all `AsFake` helpers (`mock`, `partialMock`, `spy`, `shouldRun`, `shouldNotRun`, `allowToRun`). +- Clarifies when to assert execution versus non-execution. +- Covers fake lifecycle checks/reset (`isFake`, `clearFake`). +- Provides branch-oriented test examples for orchestration confidence. + +## Core methods + +- `mock()` +- `partialMock()` +- `spy()` +- `shouldRun()` +- `shouldNotRun()` +- `allowToRun()` +- `isFake()` +- `clearFake()` + +## Recommended pattern + +- Test `handle(...)` directly for business rules. +- Test entrypoints for wiring/orchestration. +- Fake only at the boundary under test. + +## Methods provided (`AsFake` trait) + +### `mock` + +Swaps the action with a full mock. + +```php +FetchContactsFromGoogle::mock() + ->shouldReceive('handle') + ->with(42) + ->andReturn(['Loris', 'Will', 'Barney']); +``` + +### `partialMock` + +Swaps the action with a partial mock. + +```php +FetchContactsFromGoogle::partialMock() + ->shouldReceive('fetch') + ->with('some_google_identifier') + ->andReturn(['Loris', 'Will', 'Barney']); +``` + +### `spy` + +Swaps the action with a spy. + +```php +$spy = FetchContactsFromGoogle::spy() + ->allows('handle') + ->andReturn(['Loris', 'Will', 'Barney']); + +// ... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +### `shouldRun` + +Helper adding expectation on `handle`. + +```php +FetchContactsFromGoogle::shouldRun(); + +// Equivalent to: +FetchContactsFromGoogle::mock()->shouldReceive('handle'); +``` + +### `shouldNotRun` + +Helper adding negative expectation on `handle`. + +```php +FetchContactsFromGoogle::shouldNotRun(); + +// Equivalent to: +FetchContactsFromGoogle::mock()->shouldNotReceive('handle'); +``` + +### `allowToRun` + +Helper allowing `handle` on a spy. + +```php +$spy = FetchContactsFromGoogle::allowToRun() + ->andReturn(['Loris', 'Will', 'Barney']); + +// ... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +### `isFake` + +Returns whether the action has been swapped with a fake. + +```php +FetchContactsFromGoogle::isFake(); // false +FetchContactsFromGoogle::mock(); +FetchContactsFromGoogle::isFake(); // true +``` + +### `clearFake` + +Clears the fake instance, if any. + +```php +FetchContactsFromGoogle::mock(); +FetchContactsFromGoogle::isFake(); // true +FetchContactsFromGoogle::clearFake(); +FetchContactsFromGoogle::isFake(); // false +``` + +## Examples + +### Orchestration test + +```php +it('runs sync contacts for premium teams', function () { + SyncGoogleContacts::shouldRun()->once()->with(42)->andReturnTrue(); + + ImportTeamContacts::run(42, isPremium: true); +}); +``` + +### Guard-clause test + +```php +it('does not run sync when integration is disabled', function () { + SyncGoogleContacts::shouldNotRun(); + + ImportTeamContacts::run(42, integrationEnabled: false); +}); +``` + +## Checklist + +- Assertions verify call intent and argument contracts. +- Fakes are cleared when leakage risk exists. +- Branch tests use `shouldRun()` / `shouldNotRun()` where clearer. + +## Common pitfalls + +- Over-mocking and losing behavior confidence. +- Asserting only dispatch, not business correctness. + +## References + +- https://www.laravelactions.com/2.x/as-fake.html \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/troubleshooting.md b/.agents/skills/laravel-actions/references/troubleshooting.md new file mode 100644 index 000000000..cf6a5800f --- /dev/null +++ b/.agents/skills/laravel-actions/references/troubleshooting.md @@ -0,0 +1,33 @@ +# Troubleshooting + +## Scope + +Use this reference when action wiring behaves unexpectedly. + +## Recap + +- Provides a fast triage flow for routing, queueing, events, and command wiring. +- Lists recurring failure patterns and where to check first. +- Encourages reproducing issues with focused tests before broad debugging. +- Separates wiring diagnostics from domain logic verification. + +## Fast checks + +- Action class uses `AsAction`. +- Namespace and autoloading are correct. +- Entrypoint wiring (route, queue, event, command) is registered. +- Method signatures and argument types match caller expectations. + +## Failure patterns + +- Controller route points to wrong class. +- Queue worker/config mismatch. +- Listener mapping not loaded. +- Command signature mismatch. +- Command not registered in the console kernel. + +## Debug checklist + +- Reproduce with a focused failing test. +- Validate wiring layer first, then domain behavior. +- Isolate dependencies with fakes/spies where appropriate. \ No newline at end of file diff --git a/.agents/skills/laravel-actions/references/with-attributes.md b/.agents/skills/laravel-actions/references/with-attributes.md new file mode 100644 index 000000000..1b28cf2cb --- /dev/null +++ b/.agents/skills/laravel-actions/references/with-attributes.md @@ -0,0 +1,189 @@ +# With Attributes (`WithAttributes` trait) + +## Scope + +Use this reference when an action stores and validates input via internal attributes instead of method arguments. + +## Recap + +- Documents attribute lifecycle APIs (`setRawAttributes`, `fill`, `fillFromRequest`, readers/writers). +- Clarifies behavior of key collisions (`fillFromRequest`: request data wins over route params). +- Lists validation/authorization hooks reused from controller validation pipeline. +- Includes end-to-end example from fill to `validateAttributes()` and `handle(...)`. + +## Methods provided (`WithAttributes` trait) + +### `setRawAttributes` + +Replaces all attributes with the provided payload. + +```php +$action->setRawAttributes([ + 'key' => 'value', +]); +``` + +### `fill` + +Merges provided attributes into existing attributes. + +```php +$action->fill([ + 'key' => 'value', +]); +``` + +### `fillFromRequest` + +Merges request input and route parameters into attributes. Request input has priority over route parameters when keys collide. + +```php +$action->fillFromRequest($request); +``` + +### `all` + +Returns all attributes. + +```php +$action->all(); +``` + +### `only` + +Returns attributes matching the provided keys. + +```php +$action->only('title', 'body'); +``` + +### `except` + +Returns attributes excluding the provided keys. + +```php +$action->except('body'); +``` + +### `has` + +Returns whether an attribute exists for the given key. + +```php +$action->has('title'); +``` + +### `get` + +Returns the attribute value by key, with optional default. + +```php +$action->get('title'); +$action->get('title', 'Untitled'); +``` + +### `set` + +Sets an attribute value by key. + +```php +$action->set('title', 'My blog post'); +``` + +### `__get` + +Accesses attributes as object properties. + +```php +$action->title; +``` + +### `__set` + +Updates attributes as object properties. + +```php +$action->title = 'My blog post'; +``` + +### `__isset` + +Checks attribute existence as object properties. + +```php +isset($action->title); +``` + +### `validateAttributes` + +Runs authorization and validation using action attributes and returns validated data. + +```php +$validatedData = $action->validateAttributes(); +``` + +## Methods used (`AttributeValidator`) + +`WithAttributes` uses the same authorization/validation hooks as `AsController`: + +- `prepareForValidation` +- `authorize` +- `rules` +- `withValidator` +- `afterValidator` +- `getValidator` +- `getValidationData` +- `getValidationMessages` +- `getValidationAttributes` +- `getValidationRedirect` +- `getValidationErrorBag` +- `getValidationFailure` +- `getAuthorizationFailure` + +## Example + +```php +class CreateArticle +{ + use AsAction; + use WithAttributes; + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:8'], + 'body' => ['required', 'string'], + ]; + } + + public function handle(array $attributes): Article + { + return Article::create($attributes); + } +} + +$action = CreateArticle::make()->fill([ + 'title' => 'My first post', + 'body' => 'Hello world', +]); + +$validated = $action->validateAttributes(); +$article = $action->handle($validated); +``` + +## Checklist + +- Attribute keys are explicit and stable. +- Validation rules match expected attribute shape. +- `validateAttributes()` is called before side effects when needed. +- Validation/authorization hooks are tested in focused unit tests. + +## Common pitfalls + +- Mixing attribute-based and argument-based flows inconsistently in the same action. +- Assuming route params override request input in `fillFromRequest` (they do not). +- Skipping `validateAttributes()` when using external input. + +## References + +- https://www.laravelactions.com/2.x/with-attributes.html \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..d4575aac0 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): DateTime +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..db689bf77 Binary files /dev/null and b/.agents/skills/laravel-best-practices/rules/style.md differ diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md index 755d20713..70ecd57d4 100644 --- a/.agents/skills/livewire-development/SKILL.md +++ b/.agents/skills/livewire-development/SKILL.md @@ -1,24 +1,13 @@ --- name: livewire-development -description: >- - Develops reactive Livewire 3 components. Activates when creating, updating, or modifying - Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; - adding real-time updates, loading states, or reactivity; debugging component behavior; - writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel --- # Livewire Development -## When to Apply - -Activate this skill when: -- Creating new Livewire components -- Modifying existing component state or behavior -- Debugging reactivity or lifecycle issues -- Writing Livewire component tests -- Adding Alpine.js interactivity to components -- Working with wire: directives - ## Documentation Use `search-docs` for detailed Livewire 3 patterns and documentation. @@ -62,33 +51,31 @@ ### Component Structure ### Using Keys in Loops - - + +```blade @foreach ($items as $item)
{{ $item->name }}
@endforeach - -
+``` ### Lifecycle Hooks Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - - + +```php public function mount(User $user) { $this->user = $user; } public function updatedSearch() { $this->resetPage(); } - - +``` ## JavaScript Hooks You can listen for `livewire:init` to hook into Livewire initialization: - - + +```js document.addEventListener('livewire:init', function () { Livewire.hook('request', ({ fail }) => { if (fail && fail.status === 419) { @@ -100,28 +87,25 @@ ## JavaScript Hooks console.error(message); }); }); - - +``` ## Testing - - + +```php Livewire::test(Counter::class) ->assertSet('count', 0) ->call('increment') ->assertSet('count', 1) ->assertSee(1) ->assertStatus(200); +``` - - - - + +```php $this->get('/posts/create') ->assertSeeLivewire(CreatePost::class); - - +``` ## Common Pitfalls diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md index 67455e7e6..ba774e71b 100644 --- a/.agents/skills/pest-testing/SKILL.md +++ b/.agents/skills/pest-testing/SKILL.md @@ -1,24 +1,13 @@ --- name: pest-testing -description: >- - Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature - tests, adding assertions, testing Livewire components, browser testing, debugging test failures, - working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, - coverage, or needs to verify functionality works. +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel --- # Pest Testing 4 -## When to Apply - -Activate this skill when: - -- Creating new tests (unit, feature, or browser) -- Modifying existing tests -- Debugging test failures -- Working with browser testing or smoke testing -- Writing architecture tests or visual regression tests - ## Documentation Use `search-docs` for detailed Pest 4 patterns and documentation. @@ -37,13 +26,12 @@ ### Test Organization ### Basic Test Structure - - + +```php it('is true', function () { expect(true)->toBeTrue(); }); - - +``` ### Running Tests @@ -55,13 +43,12 @@ ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - + +```php it('returns all', function () { $this->postJson('/api/docs', [])->assertSuccessful(); }); - - +``` | Use | Instead of | |-----|------------| @@ -77,16 +64,15 @@ ## Datasets Use datasets for repetitive tests (validation rules, etc.): - - + +```php it('has emails', function (string $email) { expect($email)->not->toBeEmpty(); })->with([ 'james' => 'james@laravel.com', 'taylor' => 'taylor@laravel.com', ]); - - +``` ## Pest 4 Features @@ -111,8 +97,8 @@ ### Browser Test Example - Switch color schemes (light/dark mode) when appropriate. - Take screenshots or pause tests for debugging. - - + +```php it('may reset the password', function () { Notification::fake(); @@ -129,20 +115,18 @@ ### Browser Test Example Notification::assertSent(ResetPassword::class); }); - - +``` ### Smoke Testing Quickly validate multiple pages have no JavaScript errors: - - + +```php $pages = visit(['/', '/about', '/contact']); $pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); - - +``` ### Visual Regression Testing @@ -156,14 +140,13 @@ ### Architecture Testing Pest 4 includes architecture testing (from Pest 3): - - + +```php arch('controllers') ->expect('App\Http\Controllers') ->toExtendNothing() ->toHaveSuffix('Controller'); - - +``` ## Common Pitfalls diff --git a/.agents/skills/socialite-development/SKILL.md b/.agents/skills/socialite-development/SKILL.md new file mode 100644 index 000000000..e660da691 --- /dev/null +++ b/.agents/skills/socialite-development/SKILL.md @@ -0,0 +1,80 @@ +--- +name: socialite-development +description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication." +license: MIT +metadata: + author: laravel +--- + +# Socialite Authentication + +## Documentation + +Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth). + +## Available Providers + +Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch` + +Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`. + +Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`. + +Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand. + +Community providers differ from built-in providers in the following ways: +- Installed via `composer require socialiteproviders/{name}` +- Must register via event listener — NOT auto-discovered like built-in providers +- Use `search-docs` for the registration pattern + +## Adding a Provider + +### 1. Configure the provider + +Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly. + +### 2. Create redirect and callback routes + +Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details. + +### 3. Authenticate and store the user + +In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`. + +### 4. Customize the redirect (optional) + +- `scopes()` — merge additional scopes with the provider's defaults +- `setScopes()` — replace all scopes entirely +- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google) +- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object. +- `stateless()` — for API/SPA contexts where session state is not maintained + +### 5. Verify + +1. Config key matches driver name exactly (check the list above for hyphenated names) +2. `client_id`, `client_secret`, and `redirect` are all present +3. Redirect URL matches what is registered in the provider's OAuth dashboard +4. Callback route handles denied grants (when user declines authorization) + +Use `search-docs` for complete code examples of each step. + +## Additional Features + +Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details. + +User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes` + +## Testing + +Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods. + +## Common Pitfalls + +- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails. +- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors. +- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely. +- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`. +- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol). +- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved. +- Community providers require event listener registration via `SocialiteWasCalled`. +- `user()` throws when the user declines authorization. Always handle denied grants. \ No newline at end of file diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md index 12bd896bb..7c8e295e8 100644 --- a/.agents/skills/tailwindcss-development/SKILL.md +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -1,24 +1,13 @@ --- name: tailwindcss-development -description: >- - Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, - working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, - typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, - hero section, cards, buttons, or any visual/UI changes. +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel --- # Tailwind CSS Development -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - ## Documentation Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. @@ -38,22 +27,24 @@ ### CSS-First Configuration In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - + +```css @theme { --color-brand: oklch(0.72 0.11 178); } - +``` ### Import Syntax In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - + +```diff - @tailwind base; - @tailwind components; - @tailwind utilities; + @import "tailwindcss"; - +``` ### Replaced Utilities @@ -77,43 +68,47 @@ ## Spacing Use `gap` utilities instead of margins for spacing between siblings: - + +```html
Item 1
Item 2
-
+``` ## Dark Mode If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - + +```html
Content adapts to color scheme
-
+``` ## Common Patterns ### Flexbox Layout - + +```html
Left content
Right content
-
+``` ### Grid Layout - + +```html
Card 1
Card 2
Card 3
-
+``` ## Common Pitfalls diff --git a/.ai/design-system.md b/.ai/design-system.md new file mode 100644 index 000000000..d22adf3c6 --- /dev/null +++ b/.ai/design-system.md @@ -0,0 +1,1666 @@ +# Coolify Design System + +> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents. + +--- + +## 1. Design Tokens + +### 1.1 Colors + +#### Brand / Accent + +| Token | Hex | Usage | +|---|---|---| +| `coollabs` | `#6b16ed` | Primary accent (light mode) | +| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) | +| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) | +| `coollabs-200` | `#5a12c7` | Highlighted button text (light) | +| `coollabs-300` | `#4a0fa3` | Deepest brand shade | +| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) | + +#### Warning Scale (used for dark-mode accent + callouts) + +| Token | Hex | +|---|---| +| `warning-50` | `#fefce8` | +| `warning-100` | `#fef9c3` | +| `warning-200` | `#fef08a` | +| `warning-300` | `#fde047` | +| `warning-400` | `#fcd452` | +| `warning-500` | `#facc15` | +| `warning-600` | `#ca8a04` | +| `warning-700` | `#a16207` | +| `warning-800` | `#854d0e` | +| `warning-900` | `#713f12` | + +#### Neutral Grays (dark mode backgrounds) + +| Token | Hex | Usage | +|---|---|---| +| `base` | `#101010` | Page background (dark) | +| `coolgray-100` | `#181818` | Component background (dark) | +| `coolgray-200` | `#202020` | Elevated surface / borders (dark) | +| `coolgray-300` | `#242424` | Input border shadow / hover (dark) | +| `coolgray-400` | `#282828` | Tooltip background (dark) | +| `coolgray-500` | `#323232` | Subtle hover overlays (dark) | + +#### Semantic + +| Token | Hex | Usage | +|---|---|---| +| `success` | `#22C55E` | Running status, success alerts | +| `error` | `#dc2626` | Stopped status, danger actions, error alerts | + +#### Light Mode Defaults + +| Element | Color | +|---|---| +| Page background | `gray-50` (`#f9fafb`) | +| Component background | `white` (`#ffffff`) | +| Borders | `neutral-200` (`#e5e5e5`) | +| Primary text | `black` (`#000000`) | +| Muted text | `neutral-500` (`#737373`) | +| Placeholder text | `neutral-300` (`#d4d4d4`) | + +### 1.2 Typography + +**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`) + +#### Heading Hierarchy + +> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds. + +| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) | +|---|---|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` | + +#### Body Text + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` | +| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` | +| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` | +| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` | + +### 1.3 Spacing Patterns + +| Context | Value | CSS | +|---|---|---| +| Component internal padding | `p-2` | `padding: 0.5rem;` | +| Callout padding | `p-4` | `padding: 1rem;` | +| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` | +| Button height | `h-8` | `height: 2rem;` | +| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` | +| Button gap | `gap-2` | `gap: 0.5rem;` | +| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` | +| Menu item gap | `gap-3` | `gap: 0.75rem;` | +| Section margin | `mb-12` | `margin-bottom: 3rem;` | +| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` | + +### 1.4 Border Radius + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` | +| Callouts | `rounded-lg` | `border-radius: 0.5rem;` | +| Badges | `rounded-full` | `border-radius: 9999px;` | +| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` | + +### 1.5 Shadows + +#### Input / Select Box-Shadow System + +Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar. + +```css +/* Default state */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; + +/* Default state (dark) */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; + +/* Focus state (light) — purple left bar */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; + +/* Focus state (dark) — yellow left bar */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; + +/* Dirty (modified) state — same as focus */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */ + +/* Disabled / Readonly */ +box-shadow: none; +``` + +#### Input-Sticky Variant (thinner border) + +```css +/* Uses 1px border instead of 2px */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5; +``` + +### 1.6 Focus Ring System + +All interactive elements (buttons, links, checkboxes) share this focus pattern: + +**Tailwind:** +``` +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */ +} + +/* dark mode */ +.dark :focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system. + +--- + +## 2. Dark Mode Strategy + +- **Toggle method**: Class-based — `.dark` class on `` element +- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));` +- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor` + +### Accent Color Swap + +| Context | Light | Dark | +|---|---|---| +| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) | +| Focus ring | `ring-coollabs` | `ring-warning` | +| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) | +| Active nav text | `text-black` | `text-warning` | +| Helper/highlight text | `text-coollabs` | `text-warning` | +| Loading spinner | `text-coollabs` | `text-warning` | +| Scrollbar thumb | `coollabs-100` | `coollabs-100` | + +### Background Hierarchy (dark) + +``` +#101010 (base) — page background + └─ #181818 (coolgray-100) — cards, inputs, components + └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active + └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders + └─ #282828 (coolgray-400) — tooltips, hover states + └─ #323232 (coolgray-500) — subtle overlays +``` + +### Background Hierarchy (light) + +``` +#f9fafb (gray-50) — page background + └─ #ffffff (white) — cards, inputs, components + └─ #e5e5e5 (neutral-200) — borders + └─ #f5f5f5 (neutral-100) — hover backgrounds + └─ #d4d4d4 (neutral-300) — deeper hover, nav active +``` + +--- + +## 3. Component Catalog + +### 3.1 Button + +#### Default + +**Tailwind:** +``` +flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm +border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 +dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 +dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit +dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent +disabled:bg-transparent disabled:text-neutral-300 +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs +dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +.button { + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; + padding: 0 0.5rem; + height: 2rem; + font-size: 0.875rem; + font-weight: 500; + text-transform: none; + color: #000; + background: #fff; + border: 2px solid #e5e5e5; + border-radius: 0.125rem; + outline: 0; + cursor: pointer; + min-width: fit-content; +} +.button:hover { background: #f5f5f5; } + +/* Dark */ +.dark .button { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .button:hover { + background: #202020; + color: #fff; +} + +/* Disabled */ +.button:disabled { + cursor: not-allowed; + border-color: transparent; + background: transparent; + color: #d4d4d4; +} +.dark .button:disabled { color: #525252; } +``` + +#### Highlighted (Primary Action) + +**Tailwind** (via `isHighlighted` attribute): +``` +text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 +border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white +dark:hover:bg-coollabs-100 dark:hover:text-white +``` + +**Plain CSS:** +```css +.button-highlighted { + color: #5a12c7; + background: #f5f0ff; + border-color: #6b16ed; +} +.button-highlighted:hover { + background: #6b16ed; + color: #fff; +} +.dark .button-highlighted { + color: #fff; + background: rgba(107, 22, 237, 0.2); + border-color: #7317ff; +} +.dark .button-highlighted:hover { + background: #7317ff; + color: #fff; +} +``` + +#### Error / Danger + +**Tailwind** (via `isError` attribute): +``` +text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 +border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white +dark:hover:bg-red-800 dark:hover:text-white +``` + +**Plain CSS:** +```css +.button-error { + color: #991b1b; + background: #fef2f2; + border-color: #fca5a5; +} +.button-error:hover { + background: #fca5a5; + color: #fff; +} +.dark .button-error { + color: #fca5a5; + background: rgba(127, 29, 29, 0.3); + border-color: #991b1b; +} +.dark .button-error:hover { + background: #991b1b; + color: #fff; +} +``` + +#### Loading Indicator + +Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`). + +--- + +### 3.2 Input + +**Tailwind:** +``` +block py-1.5 w-full text-sm text-black rounded-sm border-0 +dark:bg-coolgray-100 dark:text-white +disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 +dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 +placeholder:text-neutral-300 dark:placeholder:text-neutral-700 +read-only:text-neutral-500 read-only:bg-neutral-200 +focus-visible:outline-none +``` + +**Plain CSS:** +```css +.input { + display: block; + padding: 0.375rem 0.5rem; + width: 100%; + font-size: 0.875rem; + color: #000; + background: #fff; + border: 0; + border-radius: 0.125rem; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +} +.input:focus-visible { + outline: none; + box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +} +.input::placeholder { color: #d4d4d4; } +.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; } +.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; } +.input[type="password"] { padding-right: 2.4rem; } + +/* Dark */ +.dark .input { + background: #181818; + color: #fff; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +} +.dark .input:focus-visible { + box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +} +.dark .input::placeholder { color: #404040; } +.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; } +.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; } +``` + +#### Dirty (Modified) State + +When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes. + +--- + +### 3.3 Select + +Same base styles as Input, plus a custom dropdown arrow SVG: + +**Tailwind:** +``` +w-full block py-1.5 text-sm text-black rounded-sm border-0 +dark:bg-coolgray-100 dark:text-white +disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 +focus-visible:outline-none +``` + +**Additional plain CSS for the dropdown arrow:** +```css +.select { + /* ...same as .input base... */ + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1rem 1rem; + padding-right: 2.5rem; + appearance: none; +} + +/* Dark mode: white stroke arrow */ +.dark .select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); +} +``` + +--- + +### 3.4 Checkbox + +**Tailwind:** +``` +dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer +dark:disabled:bg-base dark:disabled:cursor-not-allowed +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs +dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Container:** +``` +flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit +dark:hover:bg-coolgray-100 cursor-pointer +``` + +**Plain CSS:** +```css +.checkbox { + border-color: #404040; + color: #282828; + background: #181818; + border-radius: 0.125rem; + cursor: pointer; +} +.checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} + +.checkbox-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + padding: 0.25rem 0.5rem 0.25rem 0; + min-width: fit-content; + cursor: pointer; +} +.dark .checkbox-container:hover { background: #181818; } +``` + +--- + +### 3.5 Textarea + +Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces). + +**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead: + +**Tailwind:** +``` +block w-full text-sm text-black rounded-sm border border-neutral-200 +dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300 +font-mono focus-visible:outline-none focus-visible:ring-2 +focus-visible:ring-coollabs dark:focus-visible:ring-warning +focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +.textarea { + display: block; + width: 100%; + font-size: 0.875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #000; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; +} +.textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed; +} +.dark .textarea { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .textarea:focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas. + +--- + +### 3.6 Box / Card + +#### Standard Box + +**Tailwind:** +``` +relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] +dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black +border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 +dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm +``` + +**Plain CSS:** +```css +.box { + position: relative; + display: flex; + flex-direction: column; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + color: #000; + cursor: pointer; + transition: background-color 150ms, color 150ms; + text-decoration: none; +} +.box:hover { background: #f5f5f5; color: #000; } + +.dark .box { + background: #181818; + border-color: #242424; + color: #fff; +} +.dark .box:hover { + background: #7317ff; + color: #fff; +} + +/* IMPORTANT: child text must also turn white/black on hover, + since description text (#737373) is invisible on purple bg */ +.box:hover .box-title { color: #000; } +.box:hover .box-description { color: #000; } +.dark .box:hover .box-title { color: #fff; } +.dark .box:hover .box-description { color: #fff; } + +/* Desktop: row layout */ +@media (min-width: 1024px) { + .box { flex-direction: row; } +} +``` + +#### Coolbox (Ring Hover) + +**Tailwind:** +``` +relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded +border border-neutral-200 dark:border-coolgray-400 hover:ring-2 +dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] +``` + +**Plain CSS:** +```css +.coolbox { + position: relative; + display: flex; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.25rem; + cursor: pointer; + transition: all 150ms; +} +.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; } + +.dark .coolbox { + background: #181818; + border-color: #282828; +} +.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; } +``` + +#### Box Text + +> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards. + +```css +.box-title { + font-weight: 700; + color: #000; /* light mode: black */ +} +.dark .box-title { + color: #fff; /* dark mode: MUST be white, not grey */ +} + +.box-description { + font-size: 0.75rem; + font-weight: 700; + color: #737373; +} +/* On hover: description must become visible against colored bg */ +.box:hover .box-description { color: #000; } +.dark .box:hover .box-description { color: #fff; } +``` + +--- + +### 3.7 Badge / Status Indicator + +**Tailwind:** +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none +border border-neutral-200 dark:border-black +``` + +**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`) + +**Plain CSS:** +```css +.badge { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: 9999px; + border: 1px solid #e5e5e5; +} +.dark .badge { border-color: #000; } + +.badge-success { background: #22C55E; } +.badge-warning { background: #fcd452; } +.badge-error { background: #dc2626; } +``` + +#### Status Text Pattern + +Status indicators combine a badge dot with text: + +```html +
+
+
+ Running +
+
+``` + +| Status | Badge Class | Text Color | +|---|---|---| +| Running | `badge-success` | `text-success` (`#22C55E`) | +| Stopped | `badge-error` | `text-error` (`#dc2626`) | +| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) | +| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) | + +--- + +### 3.8 Dropdown + +**Container Tailwind:** +``` +p-1 mt-1 bg-white border rounded-sm shadow-sm +dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300 +``` + +**Item Tailwind:** +``` +flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs +transition-colors cursor-pointer select-none dark:text-white +hover:bg-neutral-100 dark:hover:bg-coollabs +outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs +``` + +**Plain CSS:** +```css +.dropdown { + padding: 0.25rem; + margin-top: 0.25rem; + background: #fff; + border: 1px solid #d4d4d4; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} +.dark .dropdown { + background: #202020; + border-color: #242424; +} + +.dropdown-item { + display: flex; + position: relative; + gap: 0.5rem; + justify-content: flex-start; + align-items: center; + padding: 0.25rem 1rem 0.25rem 0.5rem; + width: 100%; + font-size: 0.75rem; + cursor: pointer; + user-select: none; + transition: background-color 150ms; +} +.dropdown-item:hover { background: #f5f5f5; } +.dark .dropdown-item { color: #fff; } +.dark .dropdown-item:hover { background: #6b16ed; } +``` + +--- + +### 3.9 Sidebar / Navigation + +#### Sidebar Container + Page Layout + +The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right. + +**Tailwind (sidebar wrapper — desktop):** +``` +hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0 +``` + +**Tailwind (sidebar inner — scrollable):** +``` +flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0 +``` + +**Tailwind (nav element):** +``` +flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base +``` + +**Tailwind (main content area):** +``` +lg:pl-56 +``` + +**Tailwind (main content padding):** +``` +p-4 sm:px-6 lg:px-8 lg:py-6 +``` + +**Tailwind (mobile top bar — shown on small screens, hidden on lg+):** +``` +sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden +bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50 +``` + +**Tailwind (mobile hamburger icon):** +``` +-m-2.5 p-2.5 dark:text-warning +``` + +**Plain CSS:** +```css +/* Sidebar — desktop only */ +.sidebar { + display: none; +} +@media (min-width: 1024px) { + .sidebar { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 50; + width: 14rem; /* 224px */ + min-width: 0; + } +} + +.sidebar-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: auto; + gap: 1.25rem; + min-width: 0; +} + +/* Nav element */ +.sidebar-nav { + display: flex; + flex-direction: column; + flex: 1; + padding: 0 0.5rem; + background: #fff; + border-right: 1px solid #d4d4d4; +} +.dark .sidebar-nav { + background: #101010; + border-right-color: #202020; +} + +/* Main content offset */ +@media (min-width: 1024px) { + .main-content { padding-left: 14rem; } +} + +.main-content-inner { + padding: 1rem; +} +@media (min-width: 640px) { + .main-content-inner { padding: 1rem 1.5rem; } +} +@media (min-width: 1024px) { + .main-content-inner { padding: 1.5rem 2rem; } +} + +/* Mobile top bar — visible below lg breakpoint */ +.mobile-topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + gap: 1.5rem; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(212, 212, 212, 0.5); +} +.dark .mobile-topbar { + background: rgba(16, 16, 16, 0.95); + border-bottom-color: rgba(32, 32, 32, 0.5); +} +@media (min-width: 1024px) { + .mobile-topbar { display: none; } +} + +/* Mobile sidebar overlay (shown when hamburger is tapped) */ +.sidebar-mobile { + position: relative; + display: flex; + flex: 1; + width: 100%; + max-width: 14rem; + min-width: 0; +} +.sidebar-mobile-scroll { + display: flex; + flex-direction: column; + padding-bottom: 0.5rem; + overflow-y: auto; + min-width: 14rem; + gap: 1.25rem; + min-width: 0; +} +.dark .sidebar-mobile-scroll { background: #181818; } +``` + +#### Sidebar Header (Logo + Search) + +**Tailwind:** +``` +flex lg:pt-6 pt-4 pb-4 pl-2 +``` + +**Logo:** +``` +text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity +``` + +**Search button:** +``` +flex items-center gap-1.5 px-2.5 py-1.5 +bg-neutral-100 dark:bg-coolgray-100 +border border-neutral-300 dark:border-coolgray-200 +rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors +``` + +**Search kbd hint:** +``` +px-1 py-0.5 text-xs font-semibold +text-neutral-500 dark:text-neutral-400 +bg-neutral-200 dark:bg-coolgray-200 rounded +``` + +**Plain CSS:** +```css +.sidebar-header { + display: flex; + padding: 1rem 0 1rem 0.5rem; +} +@media (min-width: 1024px) { + .sidebar-header { padding-top: 1.5rem; } +} + +.sidebar-logo { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.025em; + color: #000; + text-decoration: none; +} +.dark .sidebar-logo { color: #fff; } +.sidebar-logo:hover { opacity: 0.8; } + +.sidebar-search-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + background: #f5f5f5; + border: 1px solid #d4d4d4; + border-radius: 0.375rem; + cursor: pointer; + transition: background-color 150ms; +} +.sidebar-search-btn:hover { background: #e5e5e5; } +.dark .sidebar-search-btn { + background: #181818; + border-color: #202020; +} +.dark .sidebar-search-btn:hover { background: #202020; } + +.sidebar-search-kbd { + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: #737373; + background: #e5e5e5; + border-radius: 0.25rem; +} +.dark .sidebar-search-kbd { + color: #a3a3a3; + background: #202020; +} +``` + +#### Menu Item List + +**Tailwind (list container):** +``` +flex flex-col flex-1 gap-y-7 +``` + +**Tailwind (inner list):** +``` +flex flex-col h-full space-y-1.5 +``` + +**Plain CSS:** +```css +.menu-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 1.75rem; + list-style: none; + padding: 0; + margin: 0; +} + +.menu-list-inner { + display: flex; + flex-direction: column; + height: 100%; + gap: 0.375rem; + list-style: none; + padding: 0; + margin: 0; +} +``` + +#### Menu Item + +**Tailwind:** +``` +flex gap-3 items-center px-2 py-1 w-full text-sm +dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 +``` + +#### Menu Item Active + +**Tailwind:** +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +#### Menu Item Icon / Label + +``` +/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white +/* Label */ min-w-0 flex-1 truncate +``` + +**Plain CSS:** +```css +.menu-item { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-item:hover { background: #d4d4d4; } +.dark .menu-item:hover { background: #181818; color: #fff; } + +.menu-item-active { + color: #000; + background: #e5e5e5; + border-radius: 0.125rem; +} +.dark .menu-item-active { + background: #202020; + color: #fcd452; +} + +.menu-item-icon { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; +} + +.menu-item-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +``` + +#### Sub-Menu Item + +```css +.sub-menu-item { + /* Same as menu-item but with gap: 0.5rem and icon size 1rem */ + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; +} +.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; } +``` + +--- + +### 3.10 Callout / Alert + +Four types: `warning`, `danger`, `info`, `success`. + +**Structure:** +```html +
+
+
+
Title
+
Content
+
+
+``` + +**Base Tailwind:** +``` +relative p-4 border rounded-lg +``` + +**Type Colors:** + +| Type | Background | Border | Title Text | Body Text | +|---|---|---|---|---| +| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | +| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | +| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | +| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | + +**Plain CSS (warning example):** +```css +.callout { + position: relative; + padding: 1rem; + border: 1px solid; + border-radius: 0.5rem; +} + +.callout-warning { + background: #fefce8; + border-color: #fde047; +} +.dark .callout-warning { + background: rgba(113, 63, 18, 0.3); + border-color: #854d0e; +} + +.callout-title { + font-size: 1rem; + font-weight: 700; +} +.callout-warning .callout-title { color: #854d0e; } +.dark .callout-warning .callout-title { color: #fde047; } + +.callout-text { + margin-top: 0.5rem; + font-size: 0.875rem; +} +.callout-warning .callout-text { color: #a16207; } +.dark .callout-warning .callout-text { color: #fef08a; } +``` + +**Icon colors per type:** +- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`) +- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`) +- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`) +- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`) + +--- + +### 3.11 Toast / Notification + +**Container Tailwind:** +``` +relative flex flex-col items-start +shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] +w-full transition-all duration-100 ease-out +dark:bg-coolgray-100 bg-white +dark:border dark:border-coolgray-200 +rounded-sm sm:max-w-xs +``` + +**Plain CSS:** +```css +.toast { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + max-width: 20rem; + background: #fff; + border-radius: 0.125rem; + box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08); + transition: all 100ms ease-out; +} +.dark .toast { + background: #181818; + border: 1px solid #202020; +} +``` + +**Icon colors per toast type:** + +| Type | Color | Hex | +|---|---|---| +| Success | `text-green-500` | `#22c55e` | +| Info | `text-blue-500` | `#3b82f6` | +| Warning | `text-orange-400` | `#fb923c` | +| Danger | `text-red-500` | `#ef4444` | + +**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right. + +--- + +### 3.12 Modal + +**Tailwind (dialog-based):** +``` +rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col +``` + +**Modal Input variant container:** +``` +relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl +border rounded-sm drop-shadow-sm +bg-white border-neutral-200 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Modal Confirmation container:** +``` +relative w-full border rounded-sm +min-w-full lg:min-w-[36rem] max-w-[48rem] +max-h-[calc(100vh-2rem)] +bg-neutral-100 border-neutral-400 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Plain CSS:** +```css +.modal-box { + border-radius: 0.125rem; + max-height: calc(100vh - 5rem); + display: flex; + flex-direction: column; +} + +.modal-input { + position: relative; + width: 100%; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)); + background: #fff; + display: flex; + flex-direction: column; +} +.dark .modal-input { + background: #101010; + border-color: #242424; +} + +/* Desktop sizing */ +@media (min-width: 1024px) { + .modal-input { + width: auto; + min-width: 42rem; + max-width: 56rem; + } +} +``` + +**Modal header:** +```css +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem; + flex-shrink: 0; +} +.modal-header h3 { + font-size: 1.5rem; + font-weight: 700; +} +``` + +**Close button:** +```css +.modal-close { + width: 2rem; + height: 2rem; + border-radius: 9999px; + color: #fff; +} +.modal-close:hover { background: #242424; } +``` + +--- + +### 3.13 Slide-Over Panel + +**Tailwind:** +``` +fixed inset-y-0 right-0 flex max-w-full pl-10 +``` + +**Inner panel:** +``` +max-w-xl w-screen +flex flex-col h-full py-6 +border-l shadow-lg +bg-neutral-50 dark:bg-base +dark:border-neutral-800 border-neutral-200 +``` + +**Plain CSS:** +```css +.slide-over { + position: fixed; + top: 0; + bottom: 0; + right: 0; + display: flex; + max-width: 100%; + padding-left: 2.5rem; +} + +.slide-over-panel { + max-width: 36rem; + width: 100vw; + display: flex; + flex-direction: column; + height: 100%; + padding: 1.5rem 0; + border-left: 1px solid #e5e5e5; + box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1); + background: #fafafa; +} +.dark .slide-over-panel { + background: #101010; + border-color: #262626; +} +``` + +--- + +### 3.14 Tag + +**Tailwind:** +``` +px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500 +dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +**Plain CSS:** +```css +.tag { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + color: #737373; + background: #f5f5f5; + cursor: pointer; +} +.tag:hover { background: #e5e5e5; } +.dark .tag { background: #181818; } +.dark .tag:hover { background: #242424; } +``` + +--- + +### 3.15 Loading Spinner + +**Tailwind:** +``` +w-4 h-4 text-coollabs dark:text-warning animate-spin +``` + +**Plain CSS + SVG:** +```css +.loading-spinner { + width: 1rem; + height: 1rem; + color: #6b16ed; + animation: spin 1s linear infinite; +} +.dark .loading-spinner { color: #fcd452; } + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +**SVG structure:** +```html + + + + +``` + +--- + +### 3.16 Helper / Tooltip + +**Tailwind (trigger icon):** +``` +cursor-pointer text-coollabs dark:text-warning +``` + +**Tailwind (popup):** +``` +hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block +dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 +dark:text-neutral-300 max-w-sm whitespace-normal break-words +``` + +**Plain CSS:** +```css +.helper-icon { + cursor: pointer; + color: #6b16ed; +} +.dark .helper-icon { color: #fcd452; } + +.helper-popup { + display: none; + position: absolute; + z-index: 40; + font-size: 0.75rem; + border-radius: 0.125rem; + color: #404040; + background: #e5e5e5; + max-width: 24rem; + white-space: normal; + word-break: break-word; + padding: 1rem; +} +.dark .helper-popup { + background: #282828; + color: #d4d4d4; + border: 1px solid #323232; +} + +/* Show on parent hover */ +.helper:hover .helper-popup { display: block; } +``` + +--- + +### 3.17 Highlighted Text + +**Tailwind:** +``` +inline-block font-bold text-coollabs dark:text-warning +``` + +**Plain CSS:** +```css +.text-highlight { + display: inline-block; + font-weight: 700; + color: #6b16ed; +} +.dark .text-highlight { color: #fcd452; } +``` + +--- + +### 3.18 Scrollbar + +**Tailwind:** +``` +scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 +dark:scrollbar-track-coolgray-200 scrollbar-thin +``` + +**Plain CSS:** +```css +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #e5e5e5; } +::-webkit-scrollbar-thumb { background: #7317ff; } +.dark ::-webkit-scrollbar-track { background: #202020; } +``` + +--- + +### 3.19 Table + +**Plain CSS:** +```css +table { min-width: 100%; border-collapse: separate; } +table, tbody { border-bottom: 1px solid #d4d4d4; } +.dark table, .dark tbody { border-color: #202020; } + +thead { text-transform: uppercase; } + +tr { color: #000; } +tr:hover { background: #e5e5e5; } +.dark tr { color: #a3a3a3; } +.dark tr:hover { background: #000; } + +th { + padding: 0.875rem 0.75rem; + text-align: left; + color: #000; +} +.dark th { color: #fff; } +th:first-child { padding-left: 1.5rem; } + +td { padding: 1rem 0.75rem; white-space: nowrap; } +td:first-child { padding-left: 1.5rem; font-weight: 700; } +``` + +--- + +### 3.20 Keyboard Shortcut Indicator + +**Tailwind:** +``` +px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning +``` + +**Plain CSS:** +```css +.kbd { + padding: 0 0.5rem; + font-size: 0.75rem; + border-radius: 0.125rem; + border: 1px dashed #404040; +} +.dark .kbd { color: #fcd452; } +``` + +--- + +## 4. Base Element Styles + +These global styles are applied to all HTML elements: + +```css +/* Page */ +html, body { + width: 100%; + min-height: 100%; + background: #f9fafb; + font-family: Inter, sans-serif; +} +.dark html, .dark body { + background: #101010; + color: #a3a3a3; +} + +body { + min-height: 100vh; + font-size: 0.875rem; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Links */ +a:hover { color: #000; } +.dark a:hover { color: #fff; } + +/* Labels */ +.dark label { color: #a3a3a3; } + +/* Sections */ +section { margin-bottom: 3rem; } + +/* Default border color override */ +*, ::after, ::before, ::backdrop { + border-color: #202020; /* coolgray-200 */ +} + +/* Select options */ +.dark option { + color: #fff; + background: #181818; +} +``` + +--- + +## 5. Interactive State Reference + +### Focus + +| Element Type | Mechanism | Light | Dark | +|---|---|---|---| +| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` | +| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` | +| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) | + +### Hover + +| Element | Light | Dark | +|---|---|---| +| Button (default) | `bg-neutral-100` | `bg-coolgray-200` | +| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) | +| Button (error) | `bg-red-300` | `bg-red-800` | +| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` | +| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` | +| Menu item | `bg-neutral-300` | `bg-coolgray-100` | +| Dropdown item | `bg-neutral-100` | `bg-coollabs` | +| Table row | `bg-neutral-200` | `bg-black` | +| Link | `text-black` | `text-white` | +| Checkbox container | — | `bg-coolgray-100` | + +### Disabled + +```css +/* Universal disabled pattern */ +:disabled { + cursor: not-allowed; + color: #d4d4d4; /* neutral-300 */ + background: transparent; + border-color: transparent; +} +.dark :disabled { + color: #525252; /* neutral-600 */ +} + +/* Input-specific */ +.input:disabled { + background: #e5e5e5; /* neutral-200 */ + color: #737373; /* neutral-500 */ + box-shadow: none; +} +.dark .input:disabled { + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +### Readonly + +```css +.input:read-only { + color: #737373; + background: #e5e5e5; + box-shadow: none; +} +.dark .input:read-only { + color: #737373; + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +--- + +## 6. CSS Custom Properties (Theme Tokens) + +For use in any CSS framework or plain CSS: + +```css +:root { + /* Font */ + --font-sans: Inter, sans-serif; + + /* Brand */ + --color-base: #101010; + --color-coollabs: #6b16ed; + --color-coollabs-50: #f5f0ff; + --color-coollabs-100: #7317ff; + --color-coollabs-200: #5a12c7; + --color-coollabs-300: #4a0fa3; + + /* Neutral grays (dark backgrounds) */ + --color-coolgray-100: #181818; + --color-coolgray-200: #202020; + --color-coolgray-300: #242424; + --color-coolgray-400: #282828; + --color-coolgray-500: #323232; + + /* Warning / dark accent */ + --color-warning: #fcd452; + --color-warning-50: #fefce8; + --color-warning-100: #fef9c3; + --color-warning-200: #fef08a; + --color-warning-300: #fde047; + --color-warning-400: #fcd452; + --color-warning-500: #facc15; + --color-warning-600: #ca8a04; + --color-warning-700: #a16207; + --color-warning-800: #854d0e; + --color-warning-900: #713f12; + + /* Semantic */ + --color-success: #22C55E; + --color-error: #dc2626; +} +``` diff --git a/.claude/skills/configuring-horizon/SKILL.md b/.claude/skills/configuring-horizon/SKILL.md new file mode 100644 index 000000000..bed1e74c0 --- /dev/null +++ b/.claude/skills/configuring-horizon/SKILL.md @@ -0,0 +1,85 @@ +--- +name: configuring-horizon +description: "Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching." +license: MIT +metadata: + author: laravel +--- + +# Horizon Configuration + +## Documentation + +Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment. + +For deeper guidance on specific topics, read the relevant reference file before implementing: + +- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling +- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config +- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs +- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config + +## Basic Usage + +### Installation + +```bash +php artisan horizon:install +``` + +### Supervisor Configuration + +Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block: + + +```php +'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], +], + +'environments' => [ + 'production' => [ + 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3], + ], + 'local' => [ + 'supervisor-1' => ['maxProcesses' => 2], + ], +], +``` + +### Dashboard Authorization + +Restrict access in `App\Providers\HorizonServiceProvider`: + + +```php +protected function gate(): void +{ + Gate::define('viewHorizon', function (User $user) { + return $user->is_admin; + }); +} +``` + +## Verification + +1. Run `php artisan horizon` and visit `/horizon` +2. Confirm dashboard access is restricted as expected +3. Check that metrics populate after scheduling `horizon:snapshot` + +## Common Pitfalls + +- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported. +- Redis Cluster is not supported. Horizon requires a standalone Redis connection. +- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration. +- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it. +- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out. +- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics. +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. \ No newline at end of file diff --git a/.claude/skills/configuring-horizon/references/metrics.md b/.claude/skills/configuring-horizon/references/metrics.md new file mode 100644 index 000000000..312f79ee7 --- /dev/null +++ b/.claude/skills/configuring-horizon/references/metrics.md @@ -0,0 +1,21 @@ +# Metrics & Snapshots + +## Where to Find It + +Search with `search-docs`: +- `"horizon metrics snapshot"` for the snapshot command and scheduling +- `"horizon trim snapshots"` for retention configuration + +## What to Watch For + +### Metrics dashboard stays blank until `horizon:snapshot` is scheduled + +Running `horizon` artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every 5 minutes via Laravel's scheduler. + +### Register the snapshot in the scheduler rather than running it manually + +A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the exact scheduler registration syntax, which differs between Laravel 10 and 11+. + +### `metrics.trim_snapshots` is a snapshot count, not a time duration + +The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage. \ No newline at end of file diff --git a/.claude/skills/configuring-horizon/references/notifications.md b/.claude/skills/configuring-horizon/references/notifications.md new file mode 100644 index 000000000..943d1a26a --- /dev/null +++ b/.claude/skills/configuring-horizon/references/notifications.md @@ -0,0 +1,21 @@ +# Notifications & Alerts + +## Where to Find It + +Search with `search-docs`: +- `"horizon notifications"` for Horizon's built-in notification routing helpers +- `"horizon long wait detected"` for LongWaitDetected event details + +## What to Watch For + +### `waits` in `config/horizon.php` controls the LongWaitDetected threshold + +The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration. + +### Use Horizon's built-in notification routing in `HorizonServiceProvider` + +Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration. + +### Failed job alerts are separate from Horizon's documented notification routing + +Horizon's 12.x documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API. \ No newline at end of file diff --git a/.claude/skills/configuring-horizon/references/supervisors.md b/.claude/skills/configuring-horizon/references/supervisors.md new file mode 100644 index 000000000..9da0c1769 --- /dev/null +++ b/.claude/skills/configuring-horizon/references/supervisors.md @@ -0,0 +1,27 @@ +# Supervisor & Balancing Configuration + +## Where to Find It + +Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions: +- `"horizon supervisor configuration"` for the full options list +- `"horizon balancing strategies"` for auto, simple, and false modes +- `"horizon autoscaling workers"` for autoScalingStrategy details +- `"horizon environment configuration"` for the defaults and environments merge + +## What to Watch For + +### The `environments` array merges into `defaults` rather than replacing it + +The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`. + +### Use separate named supervisors to enforce queue priority + +Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this. + +### Use `balance: false` to keep a fixed number of workers on a dedicated queue + +Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable. + +### Set `balanceCooldown` to prevent rapid worker scaling under bursty load + +When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle. \ No newline at end of file diff --git a/.claude/skills/configuring-horizon/references/tags.md b/.claude/skills/configuring-horizon/references/tags.md new file mode 100644 index 000000000..263c955c1 --- /dev/null +++ b/.claude/skills/configuring-horizon/references/tags.md @@ -0,0 +1,21 @@ +# Tags & Silencing + +## Where to Find It + +Search with `search-docs`: +- `"horizon tags"` for the tagging API and auto-tagging behaviour +- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options + +## What to Watch For + +### Eloquent model jobs are tagged automatically without any extra code + +If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed. + +### `silenced` hides jobs from the dashboard completed list but does not stop them from running + +Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs. + +### `silenced_tags` hides all jobs carrying a matching tag from the completed list + +Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes. \ No newline at end of file diff --git a/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/fortify-development/SKILL.md similarity index 72% rename from .claude/skills/developing-with-fortify/SKILL.md rename to .claude/skills/fortify-development/SKILL.md index 2ff71a4b4..86322d9c0 100644 --- a/.claude/skills/developing-with-fortify/SKILL.md +++ b/.claude/skills/fortify-development/SKILL.md @@ -1,6 +1,9 @@ --- -name: developing-with-fortify -description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +name: fortify-development +description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' +license: MIT +metadata: + author: laravel --- # Laravel Fortify Development @@ -39,7 +42,7 @@ ### Two-Factor Authentication Setup ``` - [ ] Add TwoFactorAuthenticatable trait to User model - [ ] Enable feature in config/fortify.php -- [ ] Run migrations for 2FA columns +- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate - [ ] Set up view callbacks in FortifyServiceProvider - [ ] Create 2FA management UI - [ ] Test QR code and recovery codes @@ -75,14 +78,26 @@ ### SPA Authentication Setup ``` - [ ] Set 'views' => false in config/fortify.php -- [ ] Install and configure Laravel Sanctum -- [ ] Use 'web' guard in fortify config +- [ ] Install and configure Laravel Sanctum for session-based SPA authentication +- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication) - [ ] Set up CSRF token handling - [ ] Test XHR authentication flows ``` > Use `search-docs` for integration and SPA authentication patterns. +#### Two-Factor Authentication in SPA Mode + +When `views` is set to `false`, Fortify returns JSON responses instead of redirects. + +If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required: + +```json +{ + "two_factor": true +} +``` + ## Best Practices ### Custom Authentication Logic diff --git a/.claude/skills/laravel-actions/SKILL.md b/.claude/skills/laravel-actions/SKILL.md new file mode 100644 index 000000000..862dd55b5 --- /dev/null +++ b/.claude/skills/laravel-actions/SKILL.md @@ -0,0 +1,302 @@ +--- +name: laravel-actions +description: Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring. +--- + +# Laravel Actions or `lorisleiva/laravel-actions` + +## Overview + +Use this skill to implement or update actions based on `lorisleiva/laravel-actions` with consistent structure and predictable testing patterns. + +## Quick Workflow + +1. Confirm the package is installed with `composer show lorisleiva/laravel-actions`. +2. Create or edit an action class that uses `Lorisleiva\Actions\Concerns\AsAction`. +3. Implement `handle(...)` with the core business logic first. +4. Add adapter methods only when needed for the requested entrypoint: + - `asController` (+ route/invokable controller usage) + - `asJob` (+ dispatch) + - `asListener` (+ event listener wiring) + - `asCommand` (+ command signature/description) +5. Add or update tests for the chosen entrypoint. +6. When tests need isolation, use action fakes (`MyAction::fake()`) and assertions (`MyAction::assertDispatched()`). + +## Base Action Pattern + +Use this minimal skeleton and expand only what is needed. + +```php +handle($id)`. +- Call with dependency injection: `app(PublishArticle::class)->handle($id)`. + +### Run as Controller + +- Use route to class (invokable style), e.g. `Route::post('/articles/{id}/publish', PublishArticle::class)`. +- Add `asController(...)` for HTTP-specific adaptation and return a response. +- Add request validation (`rules()` or custom validator hooks) when input comes from HTTP. + +### Run as Job + +- Dispatch with `PublishArticle::dispatch($id)`. +- Use `asJob(...)` only for queue-specific behavior; keep domain logic in `handle(...)`. +- In this project, job Actions often define additional queue lifecycle methods and job properties for retries, uniqueness, and timing control. + +#### Project Pattern: Job Action with Extra Methods + +```php +addMinutes(30); + } + + public function getJobBackoff(): array + { + return [60, 120]; + } + + public function getJobUniqueId(Demo $demo): string + { + return $demo->id; + } + + public function handle(Demo $demo): void + { + // Core business logic. + } + + public function asJob(JobDecorator $job, Demo $demo): void + { + // Queue-specific orchestration and retry behavior. + $this->handle($demo); + } +} +``` + +Use these members only when needed: + +- `$jobTries`: max attempts for the queued execution. +- `$jobMaxExceptions`: max unhandled exceptions before failing. +- `getJobRetryUntil()`: absolute retry deadline. +- `getJobBackoff()`: retry delay strategy per attempt. +- `getJobUniqueId(...)`: deduplication key for unique jobs. +- `asJob(JobDecorator $job, ...)`: access attempt metadata and queue-only branching. + +### Run as Listener + +- Register the action class as listener in `EventServiceProvider`. +- Use `asListener(EventName $event)` and delegate to `handle(...)`. + +### Run as Command + +- Define `$commandSignature` and `$commandDescription` properties. +- Implement `asCommand(Command $command)` and keep console IO in this method only. +- Import `Command` with `use Illuminate\Console\Command;`. + +## Testing Guidance + +Use a two-layer strategy: + +1. `handle(...)` tests for business correctness. +2. entrypoint tests (`asController`, `asJob`, `asListener`, `asCommand`) for wiring/orchestration. + +### Deep Dive: `AsFake` methods (2.x) + +Reference: https://www.laravelactions.com/2.x/as-fake.html + +Use these methods intentionally based on what you want to prove. + +#### `mock()` + +- Replaces the action with a full mock. +- Best when you need strict expectations and argument assertions. + +```php +PublishArticle::mock() + ->shouldReceive('handle') + ->once() + ->with(42) + ->andReturnTrue(); +``` + +#### `partialMock()` + +- Replaces the action with a partial mock. +- Best when you want to keep most real behavior but stub one expensive/internal method. + +```php +PublishArticle::partialMock() + ->shouldReceive('fetchRemoteData') + ->once() + ->andReturn(['ok' => true]); +``` + +#### `spy()` + +- Replaces the action with a spy. +- Best for post-execution verification ("was called with X") without predefining all expectations. + +```php +$spy = PublishArticle::spy()->allows('handle')->andReturnTrue(); + +// execute code that triggers the action... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +#### `shouldRun()` + +- Shortcut for `mock()->shouldReceive('handle')`. +- Best for compact orchestration assertions. + +```php +PublishArticle::shouldRun()->once()->with(42)->andReturnTrue(); +``` + +#### `shouldNotRun()` + +- Shortcut for `mock()->shouldNotReceive('handle')`. +- Best for guard-clause tests and branch coverage. + +```php +PublishArticle::shouldNotRun(); +``` + +#### `allowToRun()` + +- Shortcut for spy + allowing `handle`. +- Best when you want execution to proceed but still assert interaction. + +```php +$spy = PublishArticle::allowToRun()->andReturnTrue(); +// ... +$spy->shouldHaveReceived('handle')->once(); +``` + +#### `isFake()` and `clearFake()` + +- `isFake()` checks whether the class is currently swapped. +- `clearFake()` resets the fake and prevents cross-test leakage. + +```php +expect(PublishArticle::isFake())->toBeFalse(); +PublishArticle::mock(); +expect(PublishArticle::isFake())->toBeTrue(); +PublishArticle::clearFake(); +expect(PublishArticle::isFake())->toBeFalse(); +``` + +### Recommended test matrix for Actions + +- Business rule test: call `handle(...)` directly with real dependencies/factories. +- HTTP wiring test: hit route/controller, fake downstream actions with `shouldRun` or `shouldNotRun`. +- Job wiring test: dispatch action as job, assert expected downstream action calls. +- Event listener test: dispatch event, assert action interaction via fake/spy. +- Console test: run artisan command, assert action invocation and output. + +### Practical defaults + +- Prefer `shouldRun()` and `shouldNotRun()` for readability in branch tests. +- Prefer `spy()`/`allowToRun()` when behavior is mostly real and you only need call verification. +- Prefer `mock()` when interaction contracts are strict and should fail fast. +- Use `clearFake()` in cleanup when a fake might leak into another test. +- Keep side effects isolated: fake only the action under test boundary, not everything. + +### Pest style examples + +```php +it('dispatches the downstream action', function () { + SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0); + + FinalizeInvoice::run(123); +}); + +it('does not dispatch when invoice is already sent', function () { + SendInvoiceEmail::shouldNotRun(); + + FinalizeInvoice::run(123, alreadySent: true); +}); +``` + +Run the minimum relevant suite first, e.g. `php artisan test --compact --filter=PublishArticle` or by specific test file. + +## Troubleshooting Checklist + +- Ensure the class uses `AsAction` and namespace matches autoload. +- Check route registration when used as controller. +- Check queue config when using `dispatch`. +- Verify event-to-listener mapping in `EventServiceProvider`. +- Keep transport concerns in adapter methods (`asController`, `asCommand`, etc.), not in `handle(...)`. + +## Common Pitfalls + +- Putting HTTP response/redirect logic inside `handle(...)` instead of `asController(...)`. +- Duplicating business rules across `as*` methods rather than delegating to `handle(...)`. +- Assuming listener wiring works without explicit registration where required. +- Testing only entrypoints and skipping direct `handle(...)` behavior tests. +- Overusing Actions for one-off, single-context logic with no reuse pressure. + +## Topic References + +Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused on workflow and decision rules. + +- Object entrypoint: `references/object.md` +- Controller entrypoint: `references/controller.md` +- Job entrypoint: `references/job.md` +- Listener entrypoint: `references/listener.md` +- Command entrypoint: `references/command.md` +- With attributes: `references/with-attributes.md` +- Testing and fakes: `references/testing-fakes.md` +- Troubleshooting: `references/troubleshooting.md` \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/command.md b/.claude/skills/laravel-actions/references/command.md new file mode 100644 index 000000000..a7b255daf --- /dev/null +++ b/.claude/skills/laravel-actions/references/command.md @@ -0,0 +1,160 @@ +# Command Entrypoint (`asCommand`) + +## Scope + +Use this reference when exposing actions as Artisan commands. + +## Recap + +- Documents command execution via `asCommand(...)` and fallback to `handle(...)`. +- Covers command metadata via methods/properties (signature, description, help, hidden). +- Includes registration example and focused artisan test pattern. +- Reinforces separation between console I/O and domain logic. + +## Recommended pattern + +- Define `$commandSignature` and `$commandDescription`. +- Implement `asCommand(Command $command)` for console I/O. +- Keep business logic in `handle(...)`. + +## Methods used (`CommandDecorator`) + +### `asCommand` + +Called when executed as a command. If missing, it falls back to `handle(...)`. + +```php +use Illuminate\Console\Command; + +class UpdateUserRole +{ + use AsAction; + + public string $commandSignature = 'users:update-role {user_id} {role}'; + + public function handle(User $user, string $newRole): void + { + $user->update(['role' => $newRole]); + } + + public function asCommand(Command $command): void + { + $this->handle( + User::findOrFail($command->argument('user_id')), + $command->argument('role') + ); + + $command->info('Done!'); + } +} +``` + +### `getCommandSignature` + +Defines the command signature. Required when registering an action as a command if no `$commandSignature` property is set. + +```php +public function getCommandSignature(): string +{ + return 'users:update-role {user_id} {role}'; +} +``` + +### `$commandSignature` + +Property alternative to `getCommandSignature`. + +```php +public string $commandSignature = 'users:update-role {user_id} {role}'; +``` + +### `getCommandDescription` + +Provides command description. + +```php +public function getCommandDescription(): string +{ + return 'Updates the role of a given user.'; +} +``` + +### `$commandDescription` + +Property alternative to `getCommandDescription`. + +```php +public string $commandDescription = 'Updates the role of a given user.'; +``` + +### `getCommandHelp` + +Provides additional help text shown with `--help`. + +```php +public function getCommandHelp(): string +{ + return 'My help message.'; +} +``` + +### `$commandHelp` + +Property alternative to `getCommandHelp`. + +```php +public string $commandHelp = 'My help message.'; +``` + +### `isCommandHidden` + +Defines whether command should be hidden from artisan list. Default is `false`. + +```php +public function isCommandHidden(): bool +{ + return true; +} +``` + +### `$commandHidden` + +Property alternative to `isCommandHidden`. + +```php +public bool $commandHidden = true; +``` + +## Examples + +### Register in console kernel + +```php +// app/Console/Kernel.php +protected $commands = [ + UpdateUserRole::class, +]; +``` + +### Focused command test + +```php +$this->artisan('users:update-role 1 admin') + ->expectsOutput('Done!') + ->assertSuccessful(); +``` + +## Checklist + +- `use Illuminate\Console\Command;` is imported. +- Signature/options/arguments are documented. +- Command test verifies invocation and output. + +## Common pitfalls + +- Mixing command I/O with domain logic in `handle(...)`. +- Missing/ambiguous command signature. + +## References + +- https://www.laravelactions.com/2.x/as-command.html \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/controller.md b/.claude/skills/laravel-actions/references/controller.md new file mode 100644 index 000000000..d48c34df8 --- /dev/null +++ b/.claude/skills/laravel-actions/references/controller.md @@ -0,0 +1,339 @@ +# Controller Entrypoint (`asController`) + +## Scope + +Use this reference when exposing an action through HTTP routes. + +## Recap + +- Documents controller lifecycle around `asController(...)` and response adapters. +- Covers routing patterns, middleware, and optional in-action `routes()` registration. +- Summarizes validation/authorization hooks used by `ActionRequest`. +- Provides extension points for JSON/HTML responses and failure customization. + +## Recommended pattern + +- Route directly to action class when appropriate. +- Keep HTTP adaptation in controller methods (`asController`, `jsonResponse`, `htmlResponse`). +- Keep domain logic in `handle(...)`. + +## Methods provided (`AsController` trait) + +### `__invoke` + +Required so Laravel can register the action class as an invokable controller. + +```php +$action($someArguments); + +// Equivalent to: +$action->handle($someArguments); +``` + +If the method does not exist, Laravel route registration fails for invokable controllers. + +```php +// Illuminate\Routing\RouteAction +protected static function makeInvokable($action) +{ + if (! method_exists($action, '__invoke')) { + throw new UnexpectedValueException("Invalid route action: [{$action}]."); + } + + return $action.'@__invoke'; +} +``` + +If you need your own `__invoke`, alias the trait implementation: + +```php +class MyAction +{ + use AsAction { + __invoke as protected invokeFromLaravelActions; + } + + public function __invoke() + { + // Custom behavior... + } +} +``` + +## Methods used (`ControllerDecorator` + `ActionRequest`) + +### `asController` + +Called when used as invokable controller. If missing, it falls back to `handle(...)`. + +```php +public function asController(User $user, Request $request): Response +{ + $article = $this->handle( + $user, + $request->get('title'), + $request->get('body') + ); + + return redirect()->route('articles.show', [$article]); +} +``` + +### `jsonResponse` + +Called after `asController` when request expects JSON. + +```php +public function jsonResponse(Article $article, Request $request): ArticleResource +{ + return new ArticleResource($article); +} +``` + +### `htmlResponse` + +Called after `asController` when request expects HTML. + +```php +public function htmlResponse(Article $article, Request $request): Response +{ + return redirect()->route('articles.show', [$article]); +} +``` + +### `getControllerMiddleware` + +Adds middleware directly on the action controller. + +```php +public function getControllerMiddleware(): array +{ + return ['auth', MyCustomMiddleware::class]; +} +``` + +### `routes` + +Defines routes directly in the action. + +```php +public static function routes(Router $router) +{ + $router->get('author/{author}/articles', static::class); +} +``` + +To enable this, register routes from actions in a service provider: + +```php +use Lorisleiva\Actions\Facades\Actions; + +Actions::registerRoutes(); +Actions::registerRoutes('app/MyCustomActionsFolder'); +Actions::registerRoutes([ + 'app/Authentication', + 'app/Billing', + 'app/TeamManagement', +]); +``` + +### `prepareForValidation` + +Called before authorization and validation are resolved. + +```php +public function prepareForValidation(ActionRequest $request): void +{ + $request->merge(['some' => 'additional data']); +} +``` + +### `authorize` + +Defines authorization logic. + +```php +public function authorize(ActionRequest $request): bool +{ + return $request->user()->role === 'author'; +} +``` + +You can also return gate responses: + +```php +use Illuminate\Auth\Access\Response; + +public function authorize(ActionRequest $request): Response +{ + if ($request->user()->role !== 'author') { + return Response::deny('You must be an author to create a new article.'); + } + + return Response::allow(); +} +``` + +### `rules` + +Defines validation rules. + +```php +public function rules(): array +{ + return [ + 'title' => ['required', 'min:8'], + 'body' => ['required', IsValidMarkdown::class], + ]; +} +``` + +### `withValidator` + +Adds custom validation logic with an after hook. + +```php +use Illuminate\Validation\Validator; + +public function withValidator(Validator $validator, ActionRequest $request): void +{ + $validator->after(function (Validator $validator) use ($request) { + if (! Hash::check($request->get('current_password'), $request->user()->password)) { + $validator->errors()->add('current_password', 'Wrong password.'); + } + }); +} +``` + +### `afterValidator` + +Alternative to add post-validation checks. + +```php +use Illuminate\Validation\Validator; + +public function afterValidator(Validator $validator, ActionRequest $request): void +{ + if (! Hash::check($request->get('current_password'), $request->user()->password)) { + $validator->errors()->add('current_password', 'Wrong password.'); + } +} +``` + +### `getValidator` + +Provides a custom validator instead of default rules pipeline. + +```php +use Illuminate\Validation\Factory; +use Illuminate\Validation\Validator; + +public function getValidator(Factory $factory, ActionRequest $request): Validator +{ + return $factory->make($request->only('title', 'body'), [ + 'title' => ['required', 'min:8'], + 'body' => ['required', IsValidMarkdown::class], + ]); +} +``` + +### `getValidationData` + +Defines which data is validated (default: `$request->all()`). + +```php +public function getValidationData(ActionRequest $request): array +{ + return $request->all(); +} +``` + +### `getValidationMessages` + +Custom validation error messages. + +```php +public function getValidationMessages(): array +{ + return [ + 'title.required' => 'Looks like you forgot the title.', + 'body.required' => 'Is that really all you have to say?', + ]; +} +``` + +### `getValidationAttributes` + +Human-friendly names for request attributes. + +```php +public function getValidationAttributes(): array +{ + return [ + 'title' => 'headline', + 'body' => 'content', + ]; +} +``` + +### `getValidationRedirect` + +Custom redirect URL on validation failure. + +```php +public function getValidationRedirect(UrlGenerator $url): string +{ + return $url->to('/my-custom-redirect-url'); +} +``` + +### `getValidationErrorBag` + +Custom error bag name on validation failure (default: `default`). + +```php +public function getValidationErrorBag(): string +{ + return 'my_custom_error_bag'; +} +``` + +### `getValidationFailure` + +Override validation failure behavior. + +```php +public function getValidationFailure(): void +{ + throw new MyCustomValidationException(); +} +``` + +### `getAuthorizationFailure` + +Override authorization failure behavior. + +```php +public function getAuthorizationFailure(): void +{ + throw new MyCustomAuthorizationException(); +} +``` + +## Checklist + +- Route wiring points to the action class. +- `asController(...)` delegates to `handle(...)`. +- Validation/authorization methods are explicit where needed. +- Response mapping is split by channel (`jsonResponse`, `htmlResponse`) when useful. +- HTTP tests cover both success and validation/authorization failure branches. + +## Common pitfalls + +- Putting response/redirect logic in `handle(...)`. +- Duplicating business rules in `asController(...)` instead of delegating. +- Assuming action route discovery works without `Actions::registerRoutes(...)` when using in-action `routes()`. + +## References + +- https://www.laravelactions.com/2.x/as-controller.html \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/job.md b/.claude/skills/laravel-actions/references/job.md new file mode 100644 index 000000000..b4c7cbea0 --- /dev/null +++ b/.claude/skills/laravel-actions/references/job.md @@ -0,0 +1,425 @@ +# Job Entrypoint (`dispatch`, `asJob`) + +## Scope + +Use this reference when running an action through queues. + +## Recap + +- Lists async/sync dispatch helpers and conditional dispatch variants. +- Covers job wrapping/chaining with `makeJob`, `makeUniqueJob`, and `withChain`. +- Documents queue assertion helpers for tests (`assertPushed*`). +- Summarizes `JobDecorator` hooks/properties for retries, uniqueness, timeout, and failure handling. + +## Recommended pattern + +- Dispatch with `Action::dispatch(...)` for async execution. +- Keep queue-specific orchestration in `asJob(...)`. +- Keep reusable business logic in `handle(...)`. + +## Methods provided (`AsJob` trait) + +### `dispatch` + +Dispatches the action asynchronously. + +```php +SendTeamReportEmail::dispatch($team); +``` + +### `dispatchIf` + +Dispatches asynchronously only if condition is met. + +```php +SendTeamReportEmail::dispatchIf($team->plan === 'premium', $team); +``` + +### `dispatchUnless` + +Dispatches asynchronously unless condition is met. + +```php +SendTeamReportEmail::dispatchUnless($team->plan === 'free', $team); +``` + +### `dispatchSync` + +Dispatches synchronously. + +```php +SendTeamReportEmail::dispatchSync($team); +``` + +### `dispatchNow` + +Alias of `dispatchSync`. + +```php +SendTeamReportEmail::dispatchNow($team); +``` + +### `dispatchAfterResponse` + +Dispatches synchronously after the HTTP response is sent. + +```php +SendTeamReportEmail::dispatchAfterResponse($team); +``` + +### `makeJob` + +Creates a `JobDecorator` wrapper. Useful with `dispatch(...)` helper or chains. + +```php +dispatch(SendTeamReportEmail::makeJob($team)); +``` + +### `makeUniqueJob` + +Creates a `UniqueJobDecorator` wrapper. Usually automatic with `ShouldBeUnique`, but can be forced. + +```php +dispatch(SendTeamReportEmail::makeUniqueJob($team)); +``` + +### `withChain` + +Attaches jobs to run after successful processing. + +```php +$chain = [ + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +]; + +CreateNewTeamReport::withChain($chain)->dispatch($team); +``` + +Equivalent using `Bus::chain(...)`: + +```php +use Illuminate\Support\Facades\Bus; + +Bus::chain([ + CreateNewTeamReport::makeJob($team), + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +])->dispatch(); +``` + +Chain assertion example: + +```php +use Illuminate\Support\Facades\Bus; + +Bus::fake(); + +Bus::assertChained([ + CreateNewTeamReport::makeJob($team), + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +]); +``` + +### `assertPushed` + +Asserts the action was queued. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertPushed(); +SendTeamReportEmail::assertPushed(3); +SendTeamReportEmail::assertPushed($callback); +SendTeamReportEmail::assertPushed(3, $callback); +``` + +`$callback` receives: +- Action instance. +- Dispatched arguments. +- `JobDecorator` instance. +- Queue name. + +### `assertNotPushed` + +Asserts the action was not queued. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertNotPushed(); +SendTeamReportEmail::assertNotPushed($callback); +``` + +### `assertPushedOn` + +Asserts the action was queued on a specific queue. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertPushedOn('reports'); +SendTeamReportEmail::assertPushedOn('reports', 3); +SendTeamReportEmail::assertPushedOn('reports', $callback); +SendTeamReportEmail::assertPushedOn('reports', 3, $callback); +``` + +## Methods used (`JobDecorator`) + +### `asJob` + +Called when dispatched as a job. Falls back to `handle(...)` if missing. + +```php +class SendTeamReportEmail +{ + use AsAction; + + public function handle(Team $team, bool $fullReport = false): void + { + // Prepare report and send it to all $team->users. + } + + public function asJob(Team $team): void + { + $this->handle($team, true); + } +} +``` + +### `getJobMiddleware` + +Adds middleware to the queued action. + +```php +public function getJobMiddleware(array $parameters): array +{ + return [new RateLimited('reports')]; +} +``` + +### `configureJob` + +Configures `JobDecorator` options. + +```php +use Lorisleiva\Actions\Decorators\JobDecorator; + +public function configureJob(JobDecorator $job): void +{ + $job->onConnection('my_connection') + ->onQueue('my_queue') + ->through(['my_middleware']) + ->chain(['my_chain']) + ->delay(60); +} +``` + +### `$jobConnection` + +Defines queue connection. + +```php +public string $jobConnection = 'my_connection'; +``` + +### `$jobQueue` + +Defines queue name. + +```php +public string $jobQueue = 'my_queue'; +``` + +### `$jobTries` + +Defines max attempts. + +```php +public int $jobTries = 10; +``` + +### `$jobMaxExceptions` + +Defines max unhandled exceptions before failure. + +```php +public int $jobMaxExceptions = 3; +``` + +### `$jobBackoff` + +Defines retry delay seconds. + +```php +public int $jobBackoff = 60; +``` + +### `getJobBackoff` + +Defines retry delay (int or per-attempt array). + +```php +public function getJobBackoff(): int +{ + return 60; +} + +public function getJobBackoff(): array +{ + return [30, 60, 120]; +} +``` + +### `$jobTimeout` + +Defines timeout in seconds. + +```php +public int $jobTimeout = 60 * 30; +``` + +### `$jobRetryUntil` + +Defines timestamp retry deadline. + +```php +public int $jobRetryUntil = 1610191764; +``` + +### `getJobRetryUntil` + +Defines retry deadline as `DateTime`. + +```php +public function getJobRetryUntil(): DateTime +{ + return now()->addMinutes(30); +} +``` + +### `getJobDisplayName` + +Customizes queued job display name. + +```php +public function getJobDisplayName(): string +{ + return 'Send team report email'; +} +``` + +### `getJobTags` + +Adds queue tags. + +```php +public function getJobTags(Team $team): array +{ + return ['report', 'team:'.$team->id]; +} +``` + +### `getJobUniqueId` + +Defines uniqueness key when using `ShouldBeUnique`. + +```php +public function getJobUniqueId(Team $team): int +{ + return $team->id; +} +``` + +### `$jobUniqueId` + +Static uniqueness key alternative. + +```php +public string $jobUniqueId = 'some_static_key'; +``` + +### `getJobUniqueFor` + +Defines uniqueness lock duration in seconds. + +```php +public function getJobUniqueFor(Team $team): int +{ + return $team->role === 'premium' ? 1800 : 3600; +} +``` + +### `$jobUniqueFor` + +Property alternative for uniqueness lock duration. + +```php +public int $jobUniqueFor = 3600; +``` + +### `getJobUniqueVia` + +Defines cache driver used for uniqueness lock. + +```php +public function getJobUniqueVia() +{ + return Cache::driver('redis'); +} +``` + +### `$jobDeleteWhenMissingModels` + +Property alternative for missing model handling. + +```php +public bool $jobDeleteWhenMissingModels = true; +``` + +### `getJobDeleteWhenMissingModels` + +Defines whether jobs with missing models are deleted. + +```php +public function getJobDeleteWhenMissingModels(): bool +{ + return true; +} +``` + +### `jobFailed` + +Handles job failure. Receives exception and dispatched parameters. + +```php +public function jobFailed(?Throwable $e, ...$parameters): void +{ + // Notify users, report errors, trigger compensations... +} +``` + +## Checklist + +- Async/sync dispatch method matches use-case (`dispatch`, `dispatchSync`, `dispatchAfterResponse`). +- Queue config is explicit when needed (`$jobConnection`, `$jobQueue`, `configureJob`). +- Retry/backoff/timeout policies are intentional. +- `asJob(...)` delegates to `handle(...)` unless queue-specific branching is required. +- Queue tests use `Queue::fake()` and action assertions (`assertPushed*`). + +## Common pitfalls + +- Embedding domain logic only in `asJob(...)`. +- Forgetting uniqueness/timeout/retry controls on heavy jobs. +- Missing queue-specific assertions in tests. + +## References + +- https://www.laravelactions.com/2.x/as-job.html \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/listener.md b/.claude/skills/laravel-actions/references/listener.md new file mode 100644 index 000000000..c5233001d --- /dev/null +++ b/.claude/skills/laravel-actions/references/listener.md @@ -0,0 +1,81 @@ +# Listener Entrypoint (`asListener`) + +## Scope + +Use this reference when wiring actions to domain/application events. + +## Recap + +- Shows how listener execution maps event payloads into `handle(...)` arguments. +- Describes `asListener(...)` fallback behavior and adaptation role. +- Includes event registration example for provider wiring. +- Emphasizes test focus on dispatch and action interaction. + +## Recommended pattern + +- Register action listener in `EventServiceProvider` (or project equivalent). +- Use `asListener(Event $event)` for event adaptation. +- Delegate core logic to `handle(...)`. + +## Methods used (`ListenerDecorator`) + +### `asListener` + +Called when executed as an event listener. If missing, it falls back to `handle(...)`. + +```php +class SendOfferToNearbyDrivers +{ + use AsAction; + + public function handle(Address $source, Address $destination): void + { + // ... + } + + public function asListener(TaxiRequested $event): void + { + $this->handle($event->source, $event->destination); + } +} +``` + +## Examples + +### Event registration + +```php +// app/Providers/EventServiceProvider.php +protected $listen = [ + TaxiRequested::class => [ + SendOfferToNearbyDrivers::class, + ], +]; +``` + +### Focused listener test + +```php +use Illuminate\Support\Facades\Event; + +Event::fake(); + +TaxiRequested::dispatch($source, $destination); + +Event::assertDispatched(TaxiRequested::class); +``` + +## Checklist + +- Event-to-listener mapping is registered. +- Listener method signature matches event contract. +- Listener tests verify dispatch and action interaction. + +## Common pitfalls + +- Assuming automatic listener registration when explicit mapping is required. +- Re-implementing business logic in `asListener(...)`. + +## References + +- https://www.laravelactions.com/2.x/as-listener.html \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/object.md b/.claude/skills/laravel-actions/references/object.md new file mode 100644 index 000000000..6a90be4d5 --- /dev/null +++ b/.claude/skills/laravel-actions/references/object.md @@ -0,0 +1,118 @@ +# Object Entrypoint (`run`, `make`, DI) + +## Scope + +Use this reference when the action is invoked as a plain object. + +## Recap + +- Explains object-style invocation with `make`, `run`, `runIf`, `runUnless`. +- Clarifies when to use static helpers versus DI/manual invocation. +- Includes minimal examples for direct run and service-level injection. +- Highlights boundaries: business logic stays in `handle(...)`. + +## Recommended pattern + +- Keep core business logic in `handle(...)`. +- Prefer `Action::run(...)` for readability. +- Use `Action::make()->handle(...)` or DI only when needed. + +## Methods provided + +### `make` + +Resolves the action from the container. + +```php +PublishArticle::make(); + +// Equivalent to: +app(PublishArticle::class); +``` + +### `run` + +Resolves and executes the action. + +```php +PublishArticle::run($articleId); + +// Equivalent to: +PublishArticle::make()->handle($articleId); +``` + +### `runIf` + +Resolves and executes the action only if the condition is met. + +```php +PublishArticle::runIf($shouldPublish, $articleId); + +// Equivalent mental model: +if ($shouldPublish) { + PublishArticle::run($articleId); +} +``` + +### `runUnless` + +Resolves and executes the action only if the condition is not met. + +```php +PublishArticle::runUnless($alreadyPublished, $articleId); + +// Equivalent mental model: +if (! $alreadyPublished) { + PublishArticle::run($articleId); +} +``` + +## Checklist + +- Input/output types are explicit. +- `handle(...)` has no transport concerns. +- Business behavior is covered by direct `handle(...)` tests. + +## Common pitfalls + +- Putting HTTP/CLI/queue concerns in `handle(...)`. +- Calling adapters from `handle(...)` instead of the reverse. + +## References + +- https://www.laravelactions.com/2.x/as-object.html + +## Examples + +### Minimal object-style invocation + +```php +final class PublishArticle +{ + use AsAction; + + public function handle(int $articleId): bool + { + // Domain logic... + return true; + } +} + +$published = PublishArticle::run(42); +``` + +### Dependency injection invocation + +```php +final class ArticleService +{ + public function __construct( + private PublishArticle $publishArticle + ) {} + + public function publish(int $articleId): bool + { + return $this->publishArticle->handle($articleId); + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/testing-fakes.md b/.claude/skills/laravel-actions/references/testing-fakes.md new file mode 100644 index 000000000..97766e6ce --- /dev/null +++ b/.claude/skills/laravel-actions/references/testing-fakes.md @@ -0,0 +1,160 @@ +# Testing and Action Fakes + +## Scope + +Use this reference when isolating action orchestration in tests. + +## Recap + +- Summarizes all `AsFake` helpers (`mock`, `partialMock`, `spy`, `shouldRun`, `shouldNotRun`, `allowToRun`). +- Clarifies when to assert execution versus non-execution. +- Covers fake lifecycle checks/reset (`isFake`, `clearFake`). +- Provides branch-oriented test examples for orchestration confidence. + +## Core methods + +- `mock()` +- `partialMock()` +- `spy()` +- `shouldRun()` +- `shouldNotRun()` +- `allowToRun()` +- `isFake()` +- `clearFake()` + +## Recommended pattern + +- Test `handle(...)` directly for business rules. +- Test entrypoints for wiring/orchestration. +- Fake only at the boundary under test. + +## Methods provided (`AsFake` trait) + +### `mock` + +Swaps the action with a full mock. + +```php +FetchContactsFromGoogle::mock() + ->shouldReceive('handle') + ->with(42) + ->andReturn(['Loris', 'Will', 'Barney']); +``` + +### `partialMock` + +Swaps the action with a partial mock. + +```php +FetchContactsFromGoogle::partialMock() + ->shouldReceive('fetch') + ->with('some_google_identifier') + ->andReturn(['Loris', 'Will', 'Barney']); +``` + +### `spy` + +Swaps the action with a spy. + +```php +$spy = FetchContactsFromGoogle::spy() + ->allows('handle') + ->andReturn(['Loris', 'Will', 'Barney']); + +// ... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +### `shouldRun` + +Helper adding expectation on `handle`. + +```php +FetchContactsFromGoogle::shouldRun(); + +// Equivalent to: +FetchContactsFromGoogle::mock()->shouldReceive('handle'); +``` + +### `shouldNotRun` + +Helper adding negative expectation on `handle`. + +```php +FetchContactsFromGoogle::shouldNotRun(); + +// Equivalent to: +FetchContactsFromGoogle::mock()->shouldNotReceive('handle'); +``` + +### `allowToRun` + +Helper allowing `handle` on a spy. + +```php +$spy = FetchContactsFromGoogle::allowToRun() + ->andReturn(['Loris', 'Will', 'Barney']); + +// ... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +### `isFake` + +Returns whether the action has been swapped with a fake. + +```php +FetchContactsFromGoogle::isFake(); // false +FetchContactsFromGoogle::mock(); +FetchContactsFromGoogle::isFake(); // true +``` + +### `clearFake` + +Clears the fake instance, if any. + +```php +FetchContactsFromGoogle::mock(); +FetchContactsFromGoogle::isFake(); // true +FetchContactsFromGoogle::clearFake(); +FetchContactsFromGoogle::isFake(); // false +``` + +## Examples + +### Orchestration test + +```php +it('runs sync contacts for premium teams', function () { + SyncGoogleContacts::shouldRun()->once()->with(42)->andReturnTrue(); + + ImportTeamContacts::run(42, isPremium: true); +}); +``` + +### Guard-clause test + +```php +it('does not run sync when integration is disabled', function () { + SyncGoogleContacts::shouldNotRun(); + + ImportTeamContacts::run(42, integrationEnabled: false); +}); +``` + +## Checklist + +- Assertions verify call intent and argument contracts. +- Fakes are cleared when leakage risk exists. +- Branch tests use `shouldRun()` / `shouldNotRun()` where clearer. + +## Common pitfalls + +- Over-mocking and losing behavior confidence. +- Asserting only dispatch, not business correctness. + +## References + +- https://www.laravelactions.com/2.x/as-fake.html \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/troubleshooting.md b/.claude/skills/laravel-actions/references/troubleshooting.md new file mode 100644 index 000000000..cf6a5800f --- /dev/null +++ b/.claude/skills/laravel-actions/references/troubleshooting.md @@ -0,0 +1,33 @@ +# Troubleshooting + +## Scope + +Use this reference when action wiring behaves unexpectedly. + +## Recap + +- Provides a fast triage flow for routing, queueing, events, and command wiring. +- Lists recurring failure patterns and where to check first. +- Encourages reproducing issues with focused tests before broad debugging. +- Separates wiring diagnostics from domain logic verification. + +## Fast checks + +- Action class uses `AsAction`. +- Namespace and autoloading are correct. +- Entrypoint wiring (route, queue, event, command) is registered. +- Method signatures and argument types match caller expectations. + +## Failure patterns + +- Controller route points to wrong class. +- Queue worker/config mismatch. +- Listener mapping not loaded. +- Command signature mismatch. +- Command not registered in the console kernel. + +## Debug checklist + +- Reproduce with a focused failing test. +- Validate wiring layer first, then domain behavior. +- Isolate dependencies with fakes/spies where appropriate. \ No newline at end of file diff --git a/.claude/skills/laravel-actions/references/with-attributes.md b/.claude/skills/laravel-actions/references/with-attributes.md new file mode 100644 index 000000000..1b28cf2cb --- /dev/null +++ b/.claude/skills/laravel-actions/references/with-attributes.md @@ -0,0 +1,189 @@ +# With Attributes (`WithAttributes` trait) + +## Scope + +Use this reference when an action stores and validates input via internal attributes instead of method arguments. + +## Recap + +- Documents attribute lifecycle APIs (`setRawAttributes`, `fill`, `fillFromRequest`, readers/writers). +- Clarifies behavior of key collisions (`fillFromRequest`: request data wins over route params). +- Lists validation/authorization hooks reused from controller validation pipeline. +- Includes end-to-end example from fill to `validateAttributes()` and `handle(...)`. + +## Methods provided (`WithAttributes` trait) + +### `setRawAttributes` + +Replaces all attributes with the provided payload. + +```php +$action->setRawAttributes([ + 'key' => 'value', +]); +``` + +### `fill` + +Merges provided attributes into existing attributes. + +```php +$action->fill([ + 'key' => 'value', +]); +``` + +### `fillFromRequest` + +Merges request input and route parameters into attributes. Request input has priority over route parameters when keys collide. + +```php +$action->fillFromRequest($request); +``` + +### `all` + +Returns all attributes. + +```php +$action->all(); +``` + +### `only` + +Returns attributes matching the provided keys. + +```php +$action->only('title', 'body'); +``` + +### `except` + +Returns attributes excluding the provided keys. + +```php +$action->except('body'); +``` + +### `has` + +Returns whether an attribute exists for the given key. + +```php +$action->has('title'); +``` + +### `get` + +Returns the attribute value by key, with optional default. + +```php +$action->get('title'); +$action->get('title', 'Untitled'); +``` + +### `set` + +Sets an attribute value by key. + +```php +$action->set('title', 'My blog post'); +``` + +### `__get` + +Accesses attributes as object properties. + +```php +$action->title; +``` + +### `__set` + +Updates attributes as object properties. + +```php +$action->title = 'My blog post'; +``` + +### `__isset` + +Checks attribute existence as object properties. + +```php +isset($action->title); +``` + +### `validateAttributes` + +Runs authorization and validation using action attributes and returns validated data. + +```php +$validatedData = $action->validateAttributes(); +``` + +## Methods used (`AttributeValidator`) + +`WithAttributes` uses the same authorization/validation hooks as `AsController`: + +- `prepareForValidation` +- `authorize` +- `rules` +- `withValidator` +- `afterValidator` +- `getValidator` +- `getValidationData` +- `getValidationMessages` +- `getValidationAttributes` +- `getValidationRedirect` +- `getValidationErrorBag` +- `getValidationFailure` +- `getAuthorizationFailure` + +## Example + +```php +class CreateArticle +{ + use AsAction; + use WithAttributes; + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:8'], + 'body' => ['required', 'string'], + ]; + } + + public function handle(array $attributes): Article + { + return Article::create($attributes); + } +} + +$action = CreateArticle::make()->fill([ + 'title' => 'My first post', + 'body' => 'Hello world', +]); + +$validated = $action->validateAttributes(); +$article = $action->handle($validated); +``` + +## Checklist + +- Attribute keys are explicit and stable. +- Validation rules match expected attribute shape. +- `validateAttributes()` is called before side effects when needed. +- Validation/authorization hooks are tested in focused unit tests. + +## Common pitfalls + +- Mixing attribute-based and argument-based flows inconsistently in the same action. +- Assuming route params override request input in `fillFromRequest` (they do not). +- Skipping `validateAttributes()` when using external input. + +## References + +- https://www.laravelactions.com/2.x/with-attributes.html \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/.claude/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/architecture.md b/.claude/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/blade-views.md b/.claude/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/collections.md b/.claude/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/config.md b/.claude/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/db-performance.md b/.claude/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/eloquent.md b/.claude/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/events-notifications.md b/.claude/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/mail.md b/.claude/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/.claude/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..d4575aac0 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): DateTime +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/routing.md b/.claude/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/scheduling.md b/.claude/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/style.md b/.claude/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..db689bf77 Binary files /dev/null and b/.claude/skills/laravel-best-practices/rules/style.md differ diff --git a/.claude/skills/laravel-best-practices/rules/testing.md b/.claude/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/validation.md b/.claude/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md index 755d20713..70ecd57d4 100644 --- a/.claude/skills/livewire-development/SKILL.md +++ b/.claude/skills/livewire-development/SKILL.md @@ -1,24 +1,13 @@ --- name: livewire-development -description: >- - Develops reactive Livewire 3 components. Activates when creating, updating, or modifying - Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; - adding real-time updates, loading states, or reactivity; debugging component behavior; - writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel --- # Livewire Development -## When to Apply - -Activate this skill when: -- Creating new Livewire components -- Modifying existing component state or behavior -- Debugging reactivity or lifecycle issues -- Writing Livewire component tests -- Adding Alpine.js interactivity to components -- Working with wire: directives - ## Documentation Use `search-docs` for detailed Livewire 3 patterns and documentation. @@ -62,33 +51,31 @@ ### Component Structure ### Using Keys in Loops - - + +```blade @foreach ($items as $item)
{{ $item->name }}
@endforeach - -
+``` ### Lifecycle Hooks Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - - + +```php public function mount(User $user) { $this->user = $user; } public function updatedSearch() { $this->resetPage(); } - - +``` ## JavaScript Hooks You can listen for `livewire:init` to hook into Livewire initialization: - - + +```js document.addEventListener('livewire:init', function () { Livewire.hook('request', ({ fail }) => { if (fail && fail.status === 419) { @@ -100,28 +87,25 @@ ## JavaScript Hooks console.error(message); }); }); - - +``` ## Testing - - + +```php Livewire::test(Counter::class) ->assertSet('count', 0) ->call('increment') ->assertSet('count', 1) ->assertSee(1) ->assertStatus(200); +``` - - - - + +```php $this->get('/posts/create') ->assertSeeLivewire(CreatePost::class); - - +``` ## Common Pitfalls diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md index 67455e7e6..ba774e71b 100644 --- a/.claude/skills/pest-testing/SKILL.md +++ b/.claude/skills/pest-testing/SKILL.md @@ -1,24 +1,13 @@ --- name: pest-testing -description: >- - Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature - tests, adding assertions, testing Livewire components, browser testing, debugging test failures, - working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, - coverage, or needs to verify functionality works. +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel --- # Pest Testing 4 -## When to Apply - -Activate this skill when: - -- Creating new tests (unit, feature, or browser) -- Modifying existing tests -- Debugging test failures -- Working with browser testing or smoke testing -- Writing architecture tests or visual regression tests - ## Documentation Use `search-docs` for detailed Pest 4 patterns and documentation. @@ -37,13 +26,12 @@ ### Test Organization ### Basic Test Structure - - + +```php it('is true', function () { expect(true)->toBeTrue(); }); - - +``` ### Running Tests @@ -55,13 +43,12 @@ ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - + +```php it('returns all', function () { $this->postJson('/api/docs', [])->assertSuccessful(); }); - - +``` | Use | Instead of | |-----|------------| @@ -77,16 +64,15 @@ ## Datasets Use datasets for repetitive tests (validation rules, etc.): - - + +```php it('has emails', function (string $email) { expect($email)->not->toBeEmpty(); })->with([ 'james' => 'james@laravel.com', 'taylor' => 'taylor@laravel.com', ]); - - +``` ## Pest 4 Features @@ -111,8 +97,8 @@ ### Browser Test Example - Switch color schemes (light/dark mode) when appropriate. - Take screenshots or pause tests for debugging. - - + +```php it('may reset the password', function () { Notification::fake(); @@ -129,20 +115,18 @@ ### Browser Test Example Notification::assertSent(ResetPassword::class); }); - - +``` ### Smoke Testing Quickly validate multiple pages have no JavaScript errors: - - + +```php $pages = visit(['/', '/about', '/contact']); $pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); - - +``` ### Visual Regression Testing @@ -156,14 +140,13 @@ ### Architecture Testing Pest 4 includes architecture testing (from Pest 3): - - + +```php arch('controllers') ->expect('App\Http\Controllers') ->toExtendNothing() ->toHaveSuffix('Controller'); - - +``` ## Common Pitfalls diff --git a/.claude/skills/socialite-development/SKILL.md b/.claude/skills/socialite-development/SKILL.md new file mode 100644 index 000000000..e660da691 --- /dev/null +++ b/.claude/skills/socialite-development/SKILL.md @@ -0,0 +1,80 @@ +--- +name: socialite-development +description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication." +license: MIT +metadata: + author: laravel +--- + +# Socialite Authentication + +## Documentation + +Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth). + +## Available Providers + +Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch` + +Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`. + +Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`. + +Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand. + +Community providers differ from built-in providers in the following ways: +- Installed via `composer require socialiteproviders/{name}` +- Must register via event listener — NOT auto-discovered like built-in providers +- Use `search-docs` for the registration pattern + +## Adding a Provider + +### 1. Configure the provider + +Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly. + +### 2. Create redirect and callback routes + +Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details. + +### 3. Authenticate and store the user + +In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`. + +### 4. Customize the redirect (optional) + +- `scopes()` — merge additional scopes with the provider's defaults +- `setScopes()` — replace all scopes entirely +- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google) +- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object. +- `stateless()` — for API/SPA contexts where session state is not maintained + +### 5. Verify + +1. Config key matches driver name exactly (check the list above for hyphenated names) +2. `client_id`, `client_secret`, and `redirect` are all present +3. Redirect URL matches what is registered in the provider's OAuth dashboard +4. Callback route handles denied grants (when user declines authorization) + +Use `search-docs` for complete code examples of each step. + +## Additional Features + +Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details. + +User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes` + +## Testing + +Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods. + +## Common Pitfalls + +- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails. +- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors. +- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely. +- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`. +- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol). +- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved. +- Community providers require event listener registration via `SocialiteWasCalled`. +- `user()` throws when the user declines authorization. Always handle denied grants. \ No newline at end of file diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md index 12bd896bb..7c8e295e8 100644 --- a/.claude/skills/tailwindcss-development/SKILL.md +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -1,24 +1,13 @@ --- name: tailwindcss-development -description: >- - Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, - working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, - typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, - hero section, cards, buttons, or any visual/UI changes. +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel --- # Tailwind CSS Development -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - ## Documentation Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. @@ -38,22 +27,24 @@ ### CSS-First Configuration In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - + +```css @theme { --color-brand: oklch(0.72 0.11 178); } - +``` ### Import Syntax In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - + +```diff - @tailwind base; - @tailwind components; - @tailwind utilities; + @import "tailwindcss"; - +``` ### Replaced Utilities @@ -77,43 +68,47 @@ ## Spacing Use `gap` utilities instead of margins for spacing between siblings: - + +```html
Item 1
Item 2
-
+``` ## Dark Mode If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - + +```html
Content adapts to color scheme
-
+``` ## Common Patterns ### Flexbox Layout - + +```html
Left content
Right content
-
+``` ### Grid Layout - + +```html
Card 1
Card 2
Card 3
-
+``` ## Common Pitfalls diff --git a/.cursor/skills/configuring-horizon/SKILL.md b/.cursor/skills/configuring-horizon/SKILL.md new file mode 100644 index 000000000..bed1e74c0 --- /dev/null +++ b/.cursor/skills/configuring-horizon/SKILL.md @@ -0,0 +1,85 @@ +--- +name: configuring-horizon +description: "Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching." +license: MIT +metadata: + author: laravel +--- + +# Horizon Configuration + +## Documentation + +Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment. + +For deeper guidance on specific topics, read the relevant reference file before implementing: + +- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling +- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config +- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs +- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config + +## Basic Usage + +### Installation + +```bash +php artisan horizon:install +``` + +### Supervisor Configuration + +Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block: + + +```php +'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], +], + +'environments' => [ + 'production' => [ + 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3], + ], + 'local' => [ + 'supervisor-1' => ['maxProcesses' => 2], + ], +], +``` + +### Dashboard Authorization + +Restrict access in `App\Providers\HorizonServiceProvider`: + + +```php +protected function gate(): void +{ + Gate::define('viewHorizon', function (User $user) { + return $user->is_admin; + }); +} +``` + +## Verification + +1. Run `php artisan horizon` and visit `/horizon` +2. Confirm dashboard access is restricted as expected +3. Check that metrics populate after scheduling `horizon:snapshot` + +## Common Pitfalls + +- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported. +- Redis Cluster is not supported. Horizon requires a standalone Redis connection. +- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration. +- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it. +- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out. +- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics. +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. \ No newline at end of file diff --git a/.cursor/skills/configuring-horizon/references/metrics.md b/.cursor/skills/configuring-horizon/references/metrics.md new file mode 100644 index 000000000..312f79ee7 --- /dev/null +++ b/.cursor/skills/configuring-horizon/references/metrics.md @@ -0,0 +1,21 @@ +# Metrics & Snapshots + +## Where to Find It + +Search with `search-docs`: +- `"horizon metrics snapshot"` for the snapshot command and scheduling +- `"horizon trim snapshots"` for retention configuration + +## What to Watch For + +### Metrics dashboard stays blank until `horizon:snapshot` is scheduled + +Running `horizon` artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every 5 minutes via Laravel's scheduler. + +### Register the snapshot in the scheduler rather than running it manually + +A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the exact scheduler registration syntax, which differs between Laravel 10 and 11+. + +### `metrics.trim_snapshots` is a snapshot count, not a time duration + +The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage. \ No newline at end of file diff --git a/.cursor/skills/configuring-horizon/references/notifications.md b/.cursor/skills/configuring-horizon/references/notifications.md new file mode 100644 index 000000000..943d1a26a --- /dev/null +++ b/.cursor/skills/configuring-horizon/references/notifications.md @@ -0,0 +1,21 @@ +# Notifications & Alerts + +## Where to Find It + +Search with `search-docs`: +- `"horizon notifications"` for Horizon's built-in notification routing helpers +- `"horizon long wait detected"` for LongWaitDetected event details + +## What to Watch For + +### `waits` in `config/horizon.php` controls the LongWaitDetected threshold + +The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration. + +### Use Horizon's built-in notification routing in `HorizonServiceProvider` + +Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration. + +### Failed job alerts are separate from Horizon's documented notification routing + +Horizon's 12.x documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API. \ No newline at end of file diff --git a/.cursor/skills/configuring-horizon/references/supervisors.md b/.cursor/skills/configuring-horizon/references/supervisors.md new file mode 100644 index 000000000..9da0c1769 --- /dev/null +++ b/.cursor/skills/configuring-horizon/references/supervisors.md @@ -0,0 +1,27 @@ +# Supervisor & Balancing Configuration + +## Where to Find It + +Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions: +- `"horizon supervisor configuration"` for the full options list +- `"horizon balancing strategies"` for auto, simple, and false modes +- `"horizon autoscaling workers"` for autoScalingStrategy details +- `"horizon environment configuration"` for the defaults and environments merge + +## What to Watch For + +### The `environments` array merges into `defaults` rather than replacing it + +The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`. + +### Use separate named supervisors to enforce queue priority + +Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this. + +### Use `balance: false` to keep a fixed number of workers on a dedicated queue + +Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable. + +### Set `balanceCooldown` to prevent rapid worker scaling under bursty load + +When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle. \ No newline at end of file diff --git a/.cursor/skills/configuring-horizon/references/tags.md b/.cursor/skills/configuring-horizon/references/tags.md new file mode 100644 index 000000000..263c955c1 --- /dev/null +++ b/.cursor/skills/configuring-horizon/references/tags.md @@ -0,0 +1,21 @@ +# Tags & Silencing + +## Where to Find It + +Search with `search-docs`: +- `"horizon tags"` for the tagging API and auto-tagging behaviour +- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options + +## What to Watch For + +### Eloquent model jobs are tagged automatically without any extra code + +If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed. + +### `silenced` hides jobs from the dashboard completed list but does not stop them from running + +Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs. + +### `silenced_tags` hides all jobs carrying a matching tag from the completed list + +Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes. \ No newline at end of file diff --git a/.cursor/skills/developing-with-fortify/SKILL.md b/.cursor/skills/fortify-development/SKILL.md similarity index 72% rename from .cursor/skills/developing-with-fortify/SKILL.md rename to .cursor/skills/fortify-development/SKILL.md index 2ff71a4b4..86322d9c0 100644 --- a/.cursor/skills/developing-with-fortify/SKILL.md +++ b/.cursor/skills/fortify-development/SKILL.md @@ -1,6 +1,9 @@ --- -name: developing-with-fortify -description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +name: fortify-development +description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' +license: MIT +metadata: + author: laravel --- # Laravel Fortify Development @@ -39,7 +42,7 @@ ### Two-Factor Authentication Setup ``` - [ ] Add TwoFactorAuthenticatable trait to User model - [ ] Enable feature in config/fortify.php -- [ ] Run migrations for 2FA columns +- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate - [ ] Set up view callbacks in FortifyServiceProvider - [ ] Create 2FA management UI - [ ] Test QR code and recovery codes @@ -75,14 +78,26 @@ ### SPA Authentication Setup ``` - [ ] Set 'views' => false in config/fortify.php -- [ ] Install and configure Laravel Sanctum -- [ ] Use 'web' guard in fortify config +- [ ] Install and configure Laravel Sanctum for session-based SPA authentication +- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication) - [ ] Set up CSRF token handling - [ ] Test XHR authentication flows ``` > Use `search-docs` for integration and SPA authentication patterns. +#### Two-Factor Authentication in SPA Mode + +When `views` is set to `false`, Fortify returns JSON responses instead of redirects. + +If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required: + +```json +{ + "two_factor": true +} +``` + ## Best Practices ### Custom Authentication Logic diff --git a/.cursor/skills/laravel-actions/SKILL.md b/.cursor/skills/laravel-actions/SKILL.md new file mode 100644 index 000000000..862dd55b5 --- /dev/null +++ b/.cursor/skills/laravel-actions/SKILL.md @@ -0,0 +1,302 @@ +--- +name: laravel-actions +description: Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring. +--- + +# Laravel Actions or `lorisleiva/laravel-actions` + +## Overview + +Use this skill to implement or update actions based on `lorisleiva/laravel-actions` with consistent structure and predictable testing patterns. + +## Quick Workflow + +1. Confirm the package is installed with `composer show lorisleiva/laravel-actions`. +2. Create or edit an action class that uses `Lorisleiva\Actions\Concerns\AsAction`. +3. Implement `handle(...)` with the core business logic first. +4. Add adapter methods only when needed for the requested entrypoint: + - `asController` (+ route/invokable controller usage) + - `asJob` (+ dispatch) + - `asListener` (+ event listener wiring) + - `asCommand` (+ command signature/description) +5. Add or update tests for the chosen entrypoint. +6. When tests need isolation, use action fakes (`MyAction::fake()`) and assertions (`MyAction::assertDispatched()`). + +## Base Action Pattern + +Use this minimal skeleton and expand only what is needed. + +```php +handle($id)`. +- Call with dependency injection: `app(PublishArticle::class)->handle($id)`. + +### Run as Controller + +- Use route to class (invokable style), e.g. `Route::post('/articles/{id}/publish', PublishArticle::class)`. +- Add `asController(...)` for HTTP-specific adaptation and return a response. +- Add request validation (`rules()` or custom validator hooks) when input comes from HTTP. + +### Run as Job + +- Dispatch with `PublishArticle::dispatch($id)`. +- Use `asJob(...)` only for queue-specific behavior; keep domain logic in `handle(...)`. +- In this project, job Actions often define additional queue lifecycle methods and job properties for retries, uniqueness, and timing control. + +#### Project Pattern: Job Action with Extra Methods + +```php +addMinutes(30); + } + + public function getJobBackoff(): array + { + return [60, 120]; + } + + public function getJobUniqueId(Demo $demo): string + { + return $demo->id; + } + + public function handle(Demo $demo): void + { + // Core business logic. + } + + public function asJob(JobDecorator $job, Demo $demo): void + { + // Queue-specific orchestration and retry behavior. + $this->handle($demo); + } +} +``` + +Use these members only when needed: + +- `$jobTries`: max attempts for the queued execution. +- `$jobMaxExceptions`: max unhandled exceptions before failing. +- `getJobRetryUntil()`: absolute retry deadline. +- `getJobBackoff()`: retry delay strategy per attempt. +- `getJobUniqueId(...)`: deduplication key for unique jobs. +- `asJob(JobDecorator $job, ...)`: access attempt metadata and queue-only branching. + +### Run as Listener + +- Register the action class as listener in `EventServiceProvider`. +- Use `asListener(EventName $event)` and delegate to `handle(...)`. + +### Run as Command + +- Define `$commandSignature` and `$commandDescription` properties. +- Implement `asCommand(Command $command)` and keep console IO in this method only. +- Import `Command` with `use Illuminate\Console\Command;`. + +## Testing Guidance + +Use a two-layer strategy: + +1. `handle(...)` tests for business correctness. +2. entrypoint tests (`asController`, `asJob`, `asListener`, `asCommand`) for wiring/orchestration. + +### Deep Dive: `AsFake` methods (2.x) + +Reference: https://www.laravelactions.com/2.x/as-fake.html + +Use these methods intentionally based on what you want to prove. + +#### `mock()` + +- Replaces the action with a full mock. +- Best when you need strict expectations and argument assertions. + +```php +PublishArticle::mock() + ->shouldReceive('handle') + ->once() + ->with(42) + ->andReturnTrue(); +``` + +#### `partialMock()` + +- Replaces the action with a partial mock. +- Best when you want to keep most real behavior but stub one expensive/internal method. + +```php +PublishArticle::partialMock() + ->shouldReceive('fetchRemoteData') + ->once() + ->andReturn(['ok' => true]); +``` + +#### `spy()` + +- Replaces the action with a spy. +- Best for post-execution verification ("was called with X") without predefining all expectations. + +```php +$spy = PublishArticle::spy()->allows('handle')->andReturnTrue(); + +// execute code that triggers the action... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +#### `shouldRun()` + +- Shortcut for `mock()->shouldReceive('handle')`. +- Best for compact orchestration assertions. + +```php +PublishArticle::shouldRun()->once()->with(42)->andReturnTrue(); +``` + +#### `shouldNotRun()` + +- Shortcut for `mock()->shouldNotReceive('handle')`. +- Best for guard-clause tests and branch coverage. + +```php +PublishArticle::shouldNotRun(); +``` + +#### `allowToRun()` + +- Shortcut for spy + allowing `handle`. +- Best when you want execution to proceed but still assert interaction. + +```php +$spy = PublishArticle::allowToRun()->andReturnTrue(); +// ... +$spy->shouldHaveReceived('handle')->once(); +``` + +#### `isFake()` and `clearFake()` + +- `isFake()` checks whether the class is currently swapped. +- `clearFake()` resets the fake and prevents cross-test leakage. + +```php +expect(PublishArticle::isFake())->toBeFalse(); +PublishArticle::mock(); +expect(PublishArticle::isFake())->toBeTrue(); +PublishArticle::clearFake(); +expect(PublishArticle::isFake())->toBeFalse(); +``` + +### Recommended test matrix for Actions + +- Business rule test: call `handle(...)` directly with real dependencies/factories. +- HTTP wiring test: hit route/controller, fake downstream actions with `shouldRun` or `shouldNotRun`. +- Job wiring test: dispatch action as job, assert expected downstream action calls. +- Event listener test: dispatch event, assert action interaction via fake/spy. +- Console test: run artisan command, assert action invocation and output. + +### Practical defaults + +- Prefer `shouldRun()` and `shouldNotRun()` for readability in branch tests. +- Prefer `spy()`/`allowToRun()` when behavior is mostly real and you only need call verification. +- Prefer `mock()` when interaction contracts are strict and should fail fast. +- Use `clearFake()` in cleanup when a fake might leak into another test. +- Keep side effects isolated: fake only the action under test boundary, not everything. + +### Pest style examples + +```php +it('dispatches the downstream action', function () { + SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0); + + FinalizeInvoice::run(123); +}); + +it('does not dispatch when invoice is already sent', function () { + SendInvoiceEmail::shouldNotRun(); + + FinalizeInvoice::run(123, alreadySent: true); +}); +``` + +Run the minimum relevant suite first, e.g. `php artisan test --compact --filter=PublishArticle` or by specific test file. + +## Troubleshooting Checklist + +- Ensure the class uses `AsAction` and namespace matches autoload. +- Check route registration when used as controller. +- Check queue config when using `dispatch`. +- Verify event-to-listener mapping in `EventServiceProvider`. +- Keep transport concerns in adapter methods (`asController`, `asCommand`, etc.), not in `handle(...)`. + +## Common Pitfalls + +- Putting HTTP response/redirect logic inside `handle(...)` instead of `asController(...)`. +- Duplicating business rules across `as*` methods rather than delegating to `handle(...)`. +- Assuming listener wiring works without explicit registration where required. +- Testing only entrypoints and skipping direct `handle(...)` behavior tests. +- Overusing Actions for one-off, single-context logic with no reuse pressure. + +## Topic References + +Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused on workflow and decision rules. + +- Object entrypoint: `references/object.md` +- Controller entrypoint: `references/controller.md` +- Job entrypoint: `references/job.md` +- Listener entrypoint: `references/listener.md` +- Command entrypoint: `references/command.md` +- With attributes: `references/with-attributes.md` +- Testing and fakes: `references/testing-fakes.md` +- Troubleshooting: `references/troubleshooting.md` \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/command.md b/.cursor/skills/laravel-actions/references/command.md new file mode 100644 index 000000000..a7b255daf --- /dev/null +++ b/.cursor/skills/laravel-actions/references/command.md @@ -0,0 +1,160 @@ +# Command Entrypoint (`asCommand`) + +## Scope + +Use this reference when exposing actions as Artisan commands. + +## Recap + +- Documents command execution via `asCommand(...)` and fallback to `handle(...)`. +- Covers command metadata via methods/properties (signature, description, help, hidden). +- Includes registration example and focused artisan test pattern. +- Reinforces separation between console I/O and domain logic. + +## Recommended pattern + +- Define `$commandSignature` and `$commandDescription`. +- Implement `asCommand(Command $command)` for console I/O. +- Keep business logic in `handle(...)`. + +## Methods used (`CommandDecorator`) + +### `asCommand` + +Called when executed as a command. If missing, it falls back to `handle(...)`. + +```php +use Illuminate\Console\Command; + +class UpdateUserRole +{ + use AsAction; + + public string $commandSignature = 'users:update-role {user_id} {role}'; + + public function handle(User $user, string $newRole): void + { + $user->update(['role' => $newRole]); + } + + public function asCommand(Command $command): void + { + $this->handle( + User::findOrFail($command->argument('user_id')), + $command->argument('role') + ); + + $command->info('Done!'); + } +} +``` + +### `getCommandSignature` + +Defines the command signature. Required when registering an action as a command if no `$commandSignature` property is set. + +```php +public function getCommandSignature(): string +{ + return 'users:update-role {user_id} {role}'; +} +``` + +### `$commandSignature` + +Property alternative to `getCommandSignature`. + +```php +public string $commandSignature = 'users:update-role {user_id} {role}'; +``` + +### `getCommandDescription` + +Provides command description. + +```php +public function getCommandDescription(): string +{ + return 'Updates the role of a given user.'; +} +``` + +### `$commandDescription` + +Property alternative to `getCommandDescription`. + +```php +public string $commandDescription = 'Updates the role of a given user.'; +``` + +### `getCommandHelp` + +Provides additional help text shown with `--help`. + +```php +public function getCommandHelp(): string +{ + return 'My help message.'; +} +``` + +### `$commandHelp` + +Property alternative to `getCommandHelp`. + +```php +public string $commandHelp = 'My help message.'; +``` + +### `isCommandHidden` + +Defines whether command should be hidden from artisan list. Default is `false`. + +```php +public function isCommandHidden(): bool +{ + return true; +} +``` + +### `$commandHidden` + +Property alternative to `isCommandHidden`. + +```php +public bool $commandHidden = true; +``` + +## Examples + +### Register in console kernel + +```php +// app/Console/Kernel.php +protected $commands = [ + UpdateUserRole::class, +]; +``` + +### Focused command test + +```php +$this->artisan('users:update-role 1 admin') + ->expectsOutput('Done!') + ->assertSuccessful(); +``` + +## Checklist + +- `use Illuminate\Console\Command;` is imported. +- Signature/options/arguments are documented. +- Command test verifies invocation and output. + +## Common pitfalls + +- Mixing command I/O with domain logic in `handle(...)`. +- Missing/ambiguous command signature. + +## References + +- https://www.laravelactions.com/2.x/as-command.html \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/controller.md b/.cursor/skills/laravel-actions/references/controller.md new file mode 100644 index 000000000..d48c34df8 --- /dev/null +++ b/.cursor/skills/laravel-actions/references/controller.md @@ -0,0 +1,339 @@ +# Controller Entrypoint (`asController`) + +## Scope + +Use this reference when exposing an action through HTTP routes. + +## Recap + +- Documents controller lifecycle around `asController(...)` and response adapters. +- Covers routing patterns, middleware, and optional in-action `routes()` registration. +- Summarizes validation/authorization hooks used by `ActionRequest`. +- Provides extension points for JSON/HTML responses and failure customization. + +## Recommended pattern + +- Route directly to action class when appropriate. +- Keep HTTP adaptation in controller methods (`asController`, `jsonResponse`, `htmlResponse`). +- Keep domain logic in `handle(...)`. + +## Methods provided (`AsController` trait) + +### `__invoke` + +Required so Laravel can register the action class as an invokable controller. + +```php +$action($someArguments); + +// Equivalent to: +$action->handle($someArguments); +``` + +If the method does not exist, Laravel route registration fails for invokable controllers. + +```php +// Illuminate\Routing\RouteAction +protected static function makeInvokable($action) +{ + if (! method_exists($action, '__invoke')) { + throw new UnexpectedValueException("Invalid route action: [{$action}]."); + } + + return $action.'@__invoke'; +} +``` + +If you need your own `__invoke`, alias the trait implementation: + +```php +class MyAction +{ + use AsAction { + __invoke as protected invokeFromLaravelActions; + } + + public function __invoke() + { + // Custom behavior... + } +} +``` + +## Methods used (`ControllerDecorator` + `ActionRequest`) + +### `asController` + +Called when used as invokable controller. If missing, it falls back to `handle(...)`. + +```php +public function asController(User $user, Request $request): Response +{ + $article = $this->handle( + $user, + $request->get('title'), + $request->get('body') + ); + + return redirect()->route('articles.show', [$article]); +} +``` + +### `jsonResponse` + +Called after `asController` when request expects JSON. + +```php +public function jsonResponse(Article $article, Request $request): ArticleResource +{ + return new ArticleResource($article); +} +``` + +### `htmlResponse` + +Called after `asController` when request expects HTML. + +```php +public function htmlResponse(Article $article, Request $request): Response +{ + return redirect()->route('articles.show', [$article]); +} +``` + +### `getControllerMiddleware` + +Adds middleware directly on the action controller. + +```php +public function getControllerMiddleware(): array +{ + return ['auth', MyCustomMiddleware::class]; +} +``` + +### `routes` + +Defines routes directly in the action. + +```php +public static function routes(Router $router) +{ + $router->get('author/{author}/articles', static::class); +} +``` + +To enable this, register routes from actions in a service provider: + +```php +use Lorisleiva\Actions\Facades\Actions; + +Actions::registerRoutes(); +Actions::registerRoutes('app/MyCustomActionsFolder'); +Actions::registerRoutes([ + 'app/Authentication', + 'app/Billing', + 'app/TeamManagement', +]); +``` + +### `prepareForValidation` + +Called before authorization and validation are resolved. + +```php +public function prepareForValidation(ActionRequest $request): void +{ + $request->merge(['some' => 'additional data']); +} +``` + +### `authorize` + +Defines authorization logic. + +```php +public function authorize(ActionRequest $request): bool +{ + return $request->user()->role === 'author'; +} +``` + +You can also return gate responses: + +```php +use Illuminate\Auth\Access\Response; + +public function authorize(ActionRequest $request): Response +{ + if ($request->user()->role !== 'author') { + return Response::deny('You must be an author to create a new article.'); + } + + return Response::allow(); +} +``` + +### `rules` + +Defines validation rules. + +```php +public function rules(): array +{ + return [ + 'title' => ['required', 'min:8'], + 'body' => ['required', IsValidMarkdown::class], + ]; +} +``` + +### `withValidator` + +Adds custom validation logic with an after hook. + +```php +use Illuminate\Validation\Validator; + +public function withValidator(Validator $validator, ActionRequest $request): void +{ + $validator->after(function (Validator $validator) use ($request) { + if (! Hash::check($request->get('current_password'), $request->user()->password)) { + $validator->errors()->add('current_password', 'Wrong password.'); + } + }); +} +``` + +### `afterValidator` + +Alternative to add post-validation checks. + +```php +use Illuminate\Validation\Validator; + +public function afterValidator(Validator $validator, ActionRequest $request): void +{ + if (! Hash::check($request->get('current_password'), $request->user()->password)) { + $validator->errors()->add('current_password', 'Wrong password.'); + } +} +``` + +### `getValidator` + +Provides a custom validator instead of default rules pipeline. + +```php +use Illuminate\Validation\Factory; +use Illuminate\Validation\Validator; + +public function getValidator(Factory $factory, ActionRequest $request): Validator +{ + return $factory->make($request->only('title', 'body'), [ + 'title' => ['required', 'min:8'], + 'body' => ['required', IsValidMarkdown::class], + ]); +} +``` + +### `getValidationData` + +Defines which data is validated (default: `$request->all()`). + +```php +public function getValidationData(ActionRequest $request): array +{ + return $request->all(); +} +``` + +### `getValidationMessages` + +Custom validation error messages. + +```php +public function getValidationMessages(): array +{ + return [ + 'title.required' => 'Looks like you forgot the title.', + 'body.required' => 'Is that really all you have to say?', + ]; +} +``` + +### `getValidationAttributes` + +Human-friendly names for request attributes. + +```php +public function getValidationAttributes(): array +{ + return [ + 'title' => 'headline', + 'body' => 'content', + ]; +} +``` + +### `getValidationRedirect` + +Custom redirect URL on validation failure. + +```php +public function getValidationRedirect(UrlGenerator $url): string +{ + return $url->to('/my-custom-redirect-url'); +} +``` + +### `getValidationErrorBag` + +Custom error bag name on validation failure (default: `default`). + +```php +public function getValidationErrorBag(): string +{ + return 'my_custom_error_bag'; +} +``` + +### `getValidationFailure` + +Override validation failure behavior. + +```php +public function getValidationFailure(): void +{ + throw new MyCustomValidationException(); +} +``` + +### `getAuthorizationFailure` + +Override authorization failure behavior. + +```php +public function getAuthorizationFailure(): void +{ + throw new MyCustomAuthorizationException(); +} +``` + +## Checklist + +- Route wiring points to the action class. +- `asController(...)` delegates to `handle(...)`. +- Validation/authorization methods are explicit where needed. +- Response mapping is split by channel (`jsonResponse`, `htmlResponse`) when useful. +- HTTP tests cover both success and validation/authorization failure branches. + +## Common pitfalls + +- Putting response/redirect logic in `handle(...)`. +- Duplicating business rules in `asController(...)` instead of delegating. +- Assuming action route discovery works without `Actions::registerRoutes(...)` when using in-action `routes()`. + +## References + +- https://www.laravelactions.com/2.x/as-controller.html \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/job.md b/.cursor/skills/laravel-actions/references/job.md new file mode 100644 index 000000000..b4c7cbea0 --- /dev/null +++ b/.cursor/skills/laravel-actions/references/job.md @@ -0,0 +1,425 @@ +# Job Entrypoint (`dispatch`, `asJob`) + +## Scope + +Use this reference when running an action through queues. + +## Recap + +- Lists async/sync dispatch helpers and conditional dispatch variants. +- Covers job wrapping/chaining with `makeJob`, `makeUniqueJob`, and `withChain`. +- Documents queue assertion helpers for tests (`assertPushed*`). +- Summarizes `JobDecorator` hooks/properties for retries, uniqueness, timeout, and failure handling. + +## Recommended pattern + +- Dispatch with `Action::dispatch(...)` for async execution. +- Keep queue-specific orchestration in `asJob(...)`. +- Keep reusable business logic in `handle(...)`. + +## Methods provided (`AsJob` trait) + +### `dispatch` + +Dispatches the action asynchronously. + +```php +SendTeamReportEmail::dispatch($team); +``` + +### `dispatchIf` + +Dispatches asynchronously only if condition is met. + +```php +SendTeamReportEmail::dispatchIf($team->plan === 'premium', $team); +``` + +### `dispatchUnless` + +Dispatches asynchronously unless condition is met. + +```php +SendTeamReportEmail::dispatchUnless($team->plan === 'free', $team); +``` + +### `dispatchSync` + +Dispatches synchronously. + +```php +SendTeamReportEmail::dispatchSync($team); +``` + +### `dispatchNow` + +Alias of `dispatchSync`. + +```php +SendTeamReportEmail::dispatchNow($team); +``` + +### `dispatchAfterResponse` + +Dispatches synchronously after the HTTP response is sent. + +```php +SendTeamReportEmail::dispatchAfterResponse($team); +``` + +### `makeJob` + +Creates a `JobDecorator` wrapper. Useful with `dispatch(...)` helper or chains. + +```php +dispatch(SendTeamReportEmail::makeJob($team)); +``` + +### `makeUniqueJob` + +Creates a `UniqueJobDecorator` wrapper. Usually automatic with `ShouldBeUnique`, but can be forced. + +```php +dispatch(SendTeamReportEmail::makeUniqueJob($team)); +``` + +### `withChain` + +Attaches jobs to run after successful processing. + +```php +$chain = [ + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +]; + +CreateNewTeamReport::withChain($chain)->dispatch($team); +``` + +Equivalent using `Bus::chain(...)`: + +```php +use Illuminate\Support\Facades\Bus; + +Bus::chain([ + CreateNewTeamReport::makeJob($team), + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +])->dispatch(); +``` + +Chain assertion example: + +```php +use Illuminate\Support\Facades\Bus; + +Bus::fake(); + +Bus::assertChained([ + CreateNewTeamReport::makeJob($team), + OptimizeTeamReport::makeJob($team), + SendTeamReportEmail::makeJob($team), +]); +``` + +### `assertPushed` + +Asserts the action was queued. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertPushed(); +SendTeamReportEmail::assertPushed(3); +SendTeamReportEmail::assertPushed($callback); +SendTeamReportEmail::assertPushed(3, $callback); +``` + +`$callback` receives: +- Action instance. +- Dispatched arguments. +- `JobDecorator` instance. +- Queue name. + +### `assertNotPushed` + +Asserts the action was not queued. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertNotPushed(); +SendTeamReportEmail::assertNotPushed($callback); +``` + +### `assertPushedOn` + +Asserts the action was queued on a specific queue. + +```php +use Illuminate\Support\Facades\Queue; + +Queue::fake(); + +SendTeamReportEmail::assertPushedOn('reports'); +SendTeamReportEmail::assertPushedOn('reports', 3); +SendTeamReportEmail::assertPushedOn('reports', $callback); +SendTeamReportEmail::assertPushedOn('reports', 3, $callback); +``` + +## Methods used (`JobDecorator`) + +### `asJob` + +Called when dispatched as a job. Falls back to `handle(...)` if missing. + +```php +class SendTeamReportEmail +{ + use AsAction; + + public function handle(Team $team, bool $fullReport = false): void + { + // Prepare report and send it to all $team->users. + } + + public function asJob(Team $team): void + { + $this->handle($team, true); + } +} +``` + +### `getJobMiddleware` + +Adds middleware to the queued action. + +```php +public function getJobMiddleware(array $parameters): array +{ + return [new RateLimited('reports')]; +} +``` + +### `configureJob` + +Configures `JobDecorator` options. + +```php +use Lorisleiva\Actions\Decorators\JobDecorator; + +public function configureJob(JobDecorator $job): void +{ + $job->onConnection('my_connection') + ->onQueue('my_queue') + ->through(['my_middleware']) + ->chain(['my_chain']) + ->delay(60); +} +``` + +### `$jobConnection` + +Defines queue connection. + +```php +public string $jobConnection = 'my_connection'; +``` + +### `$jobQueue` + +Defines queue name. + +```php +public string $jobQueue = 'my_queue'; +``` + +### `$jobTries` + +Defines max attempts. + +```php +public int $jobTries = 10; +``` + +### `$jobMaxExceptions` + +Defines max unhandled exceptions before failure. + +```php +public int $jobMaxExceptions = 3; +``` + +### `$jobBackoff` + +Defines retry delay seconds. + +```php +public int $jobBackoff = 60; +``` + +### `getJobBackoff` + +Defines retry delay (int or per-attempt array). + +```php +public function getJobBackoff(): int +{ + return 60; +} + +public function getJobBackoff(): array +{ + return [30, 60, 120]; +} +``` + +### `$jobTimeout` + +Defines timeout in seconds. + +```php +public int $jobTimeout = 60 * 30; +``` + +### `$jobRetryUntil` + +Defines timestamp retry deadline. + +```php +public int $jobRetryUntil = 1610191764; +``` + +### `getJobRetryUntil` + +Defines retry deadline as `DateTime`. + +```php +public function getJobRetryUntil(): DateTime +{ + return now()->addMinutes(30); +} +``` + +### `getJobDisplayName` + +Customizes queued job display name. + +```php +public function getJobDisplayName(): string +{ + return 'Send team report email'; +} +``` + +### `getJobTags` + +Adds queue tags. + +```php +public function getJobTags(Team $team): array +{ + return ['report', 'team:'.$team->id]; +} +``` + +### `getJobUniqueId` + +Defines uniqueness key when using `ShouldBeUnique`. + +```php +public function getJobUniqueId(Team $team): int +{ + return $team->id; +} +``` + +### `$jobUniqueId` + +Static uniqueness key alternative. + +```php +public string $jobUniqueId = 'some_static_key'; +``` + +### `getJobUniqueFor` + +Defines uniqueness lock duration in seconds. + +```php +public function getJobUniqueFor(Team $team): int +{ + return $team->role === 'premium' ? 1800 : 3600; +} +``` + +### `$jobUniqueFor` + +Property alternative for uniqueness lock duration. + +```php +public int $jobUniqueFor = 3600; +``` + +### `getJobUniqueVia` + +Defines cache driver used for uniqueness lock. + +```php +public function getJobUniqueVia() +{ + return Cache::driver('redis'); +} +``` + +### `$jobDeleteWhenMissingModels` + +Property alternative for missing model handling. + +```php +public bool $jobDeleteWhenMissingModels = true; +``` + +### `getJobDeleteWhenMissingModels` + +Defines whether jobs with missing models are deleted. + +```php +public function getJobDeleteWhenMissingModels(): bool +{ + return true; +} +``` + +### `jobFailed` + +Handles job failure. Receives exception and dispatched parameters. + +```php +public function jobFailed(?Throwable $e, ...$parameters): void +{ + // Notify users, report errors, trigger compensations... +} +``` + +## Checklist + +- Async/sync dispatch method matches use-case (`dispatch`, `dispatchSync`, `dispatchAfterResponse`). +- Queue config is explicit when needed (`$jobConnection`, `$jobQueue`, `configureJob`). +- Retry/backoff/timeout policies are intentional. +- `asJob(...)` delegates to `handle(...)` unless queue-specific branching is required. +- Queue tests use `Queue::fake()` and action assertions (`assertPushed*`). + +## Common pitfalls + +- Embedding domain logic only in `asJob(...)`. +- Forgetting uniqueness/timeout/retry controls on heavy jobs. +- Missing queue-specific assertions in tests. + +## References + +- https://www.laravelactions.com/2.x/as-job.html \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/listener.md b/.cursor/skills/laravel-actions/references/listener.md new file mode 100644 index 000000000..c5233001d --- /dev/null +++ b/.cursor/skills/laravel-actions/references/listener.md @@ -0,0 +1,81 @@ +# Listener Entrypoint (`asListener`) + +## Scope + +Use this reference when wiring actions to domain/application events. + +## Recap + +- Shows how listener execution maps event payloads into `handle(...)` arguments. +- Describes `asListener(...)` fallback behavior and adaptation role. +- Includes event registration example for provider wiring. +- Emphasizes test focus on dispatch and action interaction. + +## Recommended pattern + +- Register action listener in `EventServiceProvider` (or project equivalent). +- Use `asListener(Event $event)` for event adaptation. +- Delegate core logic to `handle(...)`. + +## Methods used (`ListenerDecorator`) + +### `asListener` + +Called when executed as an event listener. If missing, it falls back to `handle(...)`. + +```php +class SendOfferToNearbyDrivers +{ + use AsAction; + + public function handle(Address $source, Address $destination): void + { + // ... + } + + public function asListener(TaxiRequested $event): void + { + $this->handle($event->source, $event->destination); + } +} +``` + +## Examples + +### Event registration + +```php +// app/Providers/EventServiceProvider.php +protected $listen = [ + TaxiRequested::class => [ + SendOfferToNearbyDrivers::class, + ], +]; +``` + +### Focused listener test + +```php +use Illuminate\Support\Facades\Event; + +Event::fake(); + +TaxiRequested::dispatch($source, $destination); + +Event::assertDispatched(TaxiRequested::class); +``` + +## Checklist + +- Event-to-listener mapping is registered. +- Listener method signature matches event contract. +- Listener tests verify dispatch and action interaction. + +## Common pitfalls + +- Assuming automatic listener registration when explicit mapping is required. +- Re-implementing business logic in `asListener(...)`. + +## References + +- https://www.laravelactions.com/2.x/as-listener.html \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/object.md b/.cursor/skills/laravel-actions/references/object.md new file mode 100644 index 000000000..6a90be4d5 --- /dev/null +++ b/.cursor/skills/laravel-actions/references/object.md @@ -0,0 +1,118 @@ +# Object Entrypoint (`run`, `make`, DI) + +## Scope + +Use this reference when the action is invoked as a plain object. + +## Recap + +- Explains object-style invocation with `make`, `run`, `runIf`, `runUnless`. +- Clarifies when to use static helpers versus DI/manual invocation. +- Includes minimal examples for direct run and service-level injection. +- Highlights boundaries: business logic stays in `handle(...)`. + +## Recommended pattern + +- Keep core business logic in `handle(...)`. +- Prefer `Action::run(...)` for readability. +- Use `Action::make()->handle(...)` or DI only when needed. + +## Methods provided + +### `make` + +Resolves the action from the container. + +```php +PublishArticle::make(); + +// Equivalent to: +app(PublishArticle::class); +``` + +### `run` + +Resolves and executes the action. + +```php +PublishArticle::run($articleId); + +// Equivalent to: +PublishArticle::make()->handle($articleId); +``` + +### `runIf` + +Resolves and executes the action only if the condition is met. + +```php +PublishArticle::runIf($shouldPublish, $articleId); + +// Equivalent mental model: +if ($shouldPublish) { + PublishArticle::run($articleId); +} +``` + +### `runUnless` + +Resolves and executes the action only if the condition is not met. + +```php +PublishArticle::runUnless($alreadyPublished, $articleId); + +// Equivalent mental model: +if (! $alreadyPublished) { + PublishArticle::run($articleId); +} +``` + +## Checklist + +- Input/output types are explicit. +- `handle(...)` has no transport concerns. +- Business behavior is covered by direct `handle(...)` tests. + +## Common pitfalls + +- Putting HTTP/CLI/queue concerns in `handle(...)`. +- Calling adapters from `handle(...)` instead of the reverse. + +## References + +- https://www.laravelactions.com/2.x/as-object.html + +## Examples + +### Minimal object-style invocation + +```php +final class PublishArticle +{ + use AsAction; + + public function handle(int $articleId): bool + { + // Domain logic... + return true; + } +} + +$published = PublishArticle::run(42); +``` + +### Dependency injection invocation + +```php +final class ArticleService +{ + public function __construct( + private PublishArticle $publishArticle + ) {} + + public function publish(int $articleId): bool + { + return $this->publishArticle->handle($articleId); + } +} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/testing-fakes.md b/.cursor/skills/laravel-actions/references/testing-fakes.md new file mode 100644 index 000000000..97766e6ce --- /dev/null +++ b/.cursor/skills/laravel-actions/references/testing-fakes.md @@ -0,0 +1,160 @@ +# Testing and Action Fakes + +## Scope + +Use this reference when isolating action orchestration in tests. + +## Recap + +- Summarizes all `AsFake` helpers (`mock`, `partialMock`, `spy`, `shouldRun`, `shouldNotRun`, `allowToRun`). +- Clarifies when to assert execution versus non-execution. +- Covers fake lifecycle checks/reset (`isFake`, `clearFake`). +- Provides branch-oriented test examples for orchestration confidence. + +## Core methods + +- `mock()` +- `partialMock()` +- `spy()` +- `shouldRun()` +- `shouldNotRun()` +- `allowToRun()` +- `isFake()` +- `clearFake()` + +## Recommended pattern + +- Test `handle(...)` directly for business rules. +- Test entrypoints for wiring/orchestration. +- Fake only at the boundary under test. + +## Methods provided (`AsFake` trait) + +### `mock` + +Swaps the action with a full mock. + +```php +FetchContactsFromGoogle::mock() + ->shouldReceive('handle') + ->with(42) + ->andReturn(['Loris', 'Will', 'Barney']); +``` + +### `partialMock` + +Swaps the action with a partial mock. + +```php +FetchContactsFromGoogle::partialMock() + ->shouldReceive('fetch') + ->with('some_google_identifier') + ->andReturn(['Loris', 'Will', 'Barney']); +``` + +### `spy` + +Swaps the action with a spy. + +```php +$spy = FetchContactsFromGoogle::spy() + ->allows('handle') + ->andReturn(['Loris', 'Will', 'Barney']); + +// ... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +### `shouldRun` + +Helper adding expectation on `handle`. + +```php +FetchContactsFromGoogle::shouldRun(); + +// Equivalent to: +FetchContactsFromGoogle::mock()->shouldReceive('handle'); +``` + +### `shouldNotRun` + +Helper adding negative expectation on `handle`. + +```php +FetchContactsFromGoogle::shouldNotRun(); + +// Equivalent to: +FetchContactsFromGoogle::mock()->shouldNotReceive('handle'); +``` + +### `allowToRun` + +Helper allowing `handle` on a spy. + +```php +$spy = FetchContactsFromGoogle::allowToRun() + ->andReturn(['Loris', 'Will', 'Barney']); + +// ... + +$spy->shouldHaveReceived('handle')->with(42); +``` + +### `isFake` + +Returns whether the action has been swapped with a fake. + +```php +FetchContactsFromGoogle::isFake(); // false +FetchContactsFromGoogle::mock(); +FetchContactsFromGoogle::isFake(); // true +``` + +### `clearFake` + +Clears the fake instance, if any. + +```php +FetchContactsFromGoogle::mock(); +FetchContactsFromGoogle::isFake(); // true +FetchContactsFromGoogle::clearFake(); +FetchContactsFromGoogle::isFake(); // false +``` + +## Examples + +### Orchestration test + +```php +it('runs sync contacts for premium teams', function () { + SyncGoogleContacts::shouldRun()->once()->with(42)->andReturnTrue(); + + ImportTeamContacts::run(42, isPremium: true); +}); +``` + +### Guard-clause test + +```php +it('does not run sync when integration is disabled', function () { + SyncGoogleContacts::shouldNotRun(); + + ImportTeamContacts::run(42, integrationEnabled: false); +}); +``` + +## Checklist + +- Assertions verify call intent and argument contracts. +- Fakes are cleared when leakage risk exists. +- Branch tests use `shouldRun()` / `shouldNotRun()` where clearer. + +## Common pitfalls + +- Over-mocking and losing behavior confidence. +- Asserting only dispatch, not business correctness. + +## References + +- https://www.laravelactions.com/2.x/as-fake.html \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/troubleshooting.md b/.cursor/skills/laravel-actions/references/troubleshooting.md new file mode 100644 index 000000000..cf6a5800f --- /dev/null +++ b/.cursor/skills/laravel-actions/references/troubleshooting.md @@ -0,0 +1,33 @@ +# Troubleshooting + +## Scope + +Use this reference when action wiring behaves unexpectedly. + +## Recap + +- Provides a fast triage flow for routing, queueing, events, and command wiring. +- Lists recurring failure patterns and where to check first. +- Encourages reproducing issues with focused tests before broad debugging. +- Separates wiring diagnostics from domain logic verification. + +## Fast checks + +- Action class uses `AsAction`. +- Namespace and autoloading are correct. +- Entrypoint wiring (route, queue, event, command) is registered. +- Method signatures and argument types match caller expectations. + +## Failure patterns + +- Controller route points to wrong class. +- Queue worker/config mismatch. +- Listener mapping not loaded. +- Command signature mismatch. +- Command not registered in the console kernel. + +## Debug checklist + +- Reproduce with a focused failing test. +- Validate wiring layer first, then domain behavior. +- Isolate dependencies with fakes/spies where appropriate. \ No newline at end of file diff --git a/.cursor/skills/laravel-actions/references/with-attributes.md b/.cursor/skills/laravel-actions/references/with-attributes.md new file mode 100644 index 000000000..1b28cf2cb --- /dev/null +++ b/.cursor/skills/laravel-actions/references/with-attributes.md @@ -0,0 +1,189 @@ +# With Attributes (`WithAttributes` trait) + +## Scope + +Use this reference when an action stores and validates input via internal attributes instead of method arguments. + +## Recap + +- Documents attribute lifecycle APIs (`setRawAttributes`, `fill`, `fillFromRequest`, readers/writers). +- Clarifies behavior of key collisions (`fillFromRequest`: request data wins over route params). +- Lists validation/authorization hooks reused from controller validation pipeline. +- Includes end-to-end example from fill to `validateAttributes()` and `handle(...)`. + +## Methods provided (`WithAttributes` trait) + +### `setRawAttributes` + +Replaces all attributes with the provided payload. + +```php +$action->setRawAttributes([ + 'key' => 'value', +]); +``` + +### `fill` + +Merges provided attributes into existing attributes. + +```php +$action->fill([ + 'key' => 'value', +]); +``` + +### `fillFromRequest` + +Merges request input and route parameters into attributes. Request input has priority over route parameters when keys collide. + +```php +$action->fillFromRequest($request); +``` + +### `all` + +Returns all attributes. + +```php +$action->all(); +``` + +### `only` + +Returns attributes matching the provided keys. + +```php +$action->only('title', 'body'); +``` + +### `except` + +Returns attributes excluding the provided keys. + +```php +$action->except('body'); +``` + +### `has` + +Returns whether an attribute exists for the given key. + +```php +$action->has('title'); +``` + +### `get` + +Returns the attribute value by key, with optional default. + +```php +$action->get('title'); +$action->get('title', 'Untitled'); +``` + +### `set` + +Sets an attribute value by key. + +```php +$action->set('title', 'My blog post'); +``` + +### `__get` + +Accesses attributes as object properties. + +```php +$action->title; +``` + +### `__set` + +Updates attributes as object properties. + +```php +$action->title = 'My blog post'; +``` + +### `__isset` + +Checks attribute existence as object properties. + +```php +isset($action->title); +``` + +### `validateAttributes` + +Runs authorization and validation using action attributes and returns validated data. + +```php +$validatedData = $action->validateAttributes(); +``` + +## Methods used (`AttributeValidator`) + +`WithAttributes` uses the same authorization/validation hooks as `AsController`: + +- `prepareForValidation` +- `authorize` +- `rules` +- `withValidator` +- `afterValidator` +- `getValidator` +- `getValidationData` +- `getValidationMessages` +- `getValidationAttributes` +- `getValidationRedirect` +- `getValidationErrorBag` +- `getValidationFailure` +- `getAuthorizationFailure` + +## Example + +```php +class CreateArticle +{ + use AsAction; + use WithAttributes; + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'min:8'], + 'body' => ['required', 'string'], + ]; + } + + public function handle(array $attributes): Article + { + return Article::create($attributes); + } +} + +$action = CreateArticle::make()->fill([ + 'title' => 'My first post', + 'body' => 'Hello world', +]); + +$validated = $action->validateAttributes(); +$article = $action->handle($validated); +``` + +## Checklist + +- Attribute keys are explicit and stable. +- Validation rules match expected attribute shape. +- `validateAttributes()` is called before side effects when needed. +- Validation/authorization hooks are tested in focused unit tests. + +## Common pitfalls + +- Mixing attribute-based and argument-based flows inconsistently in the same action. +- Assuming route params override request input in `fillFromRequest` (they do not). +- Skipping `validateAttributes()` when using external input. + +## References + +- https://www.laravelactions.com/2.x/with-attributes.html \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/SKILL.md b/.cursor/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/.cursor/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/advanced-queries.md b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/architecture.md b/.cursor/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/blade-views.md b/.cursor/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/caching.md b/.cursor/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/collections.md b/.cursor/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/config.md b/.cursor/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/db-performance.md b/.cursor/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/eloquent.md b/.cursor/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/error-handling.md b/.cursor/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/events-notifications.md b/.cursor/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/http-client.md b/.cursor/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/mail.md b/.cursor/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/migrations.md b/.cursor/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/queue-jobs.md b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..d4575aac0 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): DateTime +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/routing.md b/.cursor/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/scheduling.md b/.cursor/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/security.md b/.cursor/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/style.md b/.cursor/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..db689bf77 Binary files /dev/null and b/.cursor/skills/laravel-best-practices/rules/style.md differ diff --git a/.cursor/skills/laravel-best-practices/rules/testing.md b/.cursor/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.cursor/skills/laravel-best-practices/rules/validation.md b/.cursor/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.cursor/skills/livewire-development/SKILL.md b/.cursor/skills/livewire-development/SKILL.md index 755d20713..70ecd57d4 100644 --- a/.cursor/skills/livewire-development/SKILL.md +++ b/.cursor/skills/livewire-development/SKILL.md @@ -1,24 +1,13 @@ --- name: livewire-development -description: >- - Develops reactive Livewire 3 components. Activates when creating, updating, or modifying - Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; - adding real-time updates, loading states, or reactivity; debugging component behavior; - writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel --- # Livewire Development -## When to Apply - -Activate this skill when: -- Creating new Livewire components -- Modifying existing component state or behavior -- Debugging reactivity or lifecycle issues -- Writing Livewire component tests -- Adding Alpine.js interactivity to components -- Working with wire: directives - ## Documentation Use `search-docs` for detailed Livewire 3 patterns and documentation. @@ -62,33 +51,31 @@ ### Component Structure ### Using Keys in Loops - - + +```blade @foreach ($items as $item)
{{ $item->name }}
@endforeach - -
+``` ### Lifecycle Hooks Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - - + +```php public function mount(User $user) { $this->user = $user; } public function updatedSearch() { $this->resetPage(); } - - +``` ## JavaScript Hooks You can listen for `livewire:init` to hook into Livewire initialization: - - + +```js document.addEventListener('livewire:init', function () { Livewire.hook('request', ({ fail }) => { if (fail && fail.status === 419) { @@ -100,28 +87,25 @@ ## JavaScript Hooks console.error(message); }); }); - - +``` ## Testing - - + +```php Livewire::test(Counter::class) ->assertSet('count', 0) ->call('increment') ->assertSet('count', 1) ->assertSee(1) ->assertStatus(200); +``` - - - - + +```php $this->get('/posts/create') ->assertSeeLivewire(CreatePost::class); - - +``` ## Common Pitfalls diff --git a/.cursor/skills/pest-testing/SKILL.md b/.cursor/skills/pest-testing/SKILL.md index 67455e7e6..ba774e71b 100644 --- a/.cursor/skills/pest-testing/SKILL.md +++ b/.cursor/skills/pest-testing/SKILL.md @@ -1,24 +1,13 @@ --- name: pest-testing -description: >- - Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature - tests, adding assertions, testing Livewire components, browser testing, debugging test failures, - working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, - coverage, or needs to verify functionality works. +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel --- # Pest Testing 4 -## When to Apply - -Activate this skill when: - -- Creating new tests (unit, feature, or browser) -- Modifying existing tests -- Debugging test failures -- Working with browser testing or smoke testing -- Writing architecture tests or visual regression tests - ## Documentation Use `search-docs` for detailed Pest 4 patterns and documentation. @@ -37,13 +26,12 @@ ### Test Organization ### Basic Test Structure - - + +```php it('is true', function () { expect(true)->toBeTrue(); }); - - +``` ### Running Tests @@ -55,13 +43,12 @@ ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - + +```php it('returns all', function () { $this->postJson('/api/docs', [])->assertSuccessful(); }); - - +``` | Use | Instead of | |-----|------------| @@ -77,16 +64,15 @@ ## Datasets Use datasets for repetitive tests (validation rules, etc.): - - + +```php it('has emails', function (string $email) { expect($email)->not->toBeEmpty(); })->with([ 'james' => 'james@laravel.com', 'taylor' => 'taylor@laravel.com', ]); - - +``` ## Pest 4 Features @@ -111,8 +97,8 @@ ### Browser Test Example - Switch color schemes (light/dark mode) when appropriate. - Take screenshots or pause tests for debugging. - - + +```php it('may reset the password', function () { Notification::fake(); @@ -129,20 +115,18 @@ ### Browser Test Example Notification::assertSent(ResetPassword::class); }); - - +``` ### Smoke Testing Quickly validate multiple pages have no JavaScript errors: - - + +```php $pages = visit(['/', '/about', '/contact']); $pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); - - +``` ### Visual Regression Testing @@ -156,14 +140,13 @@ ### Architecture Testing Pest 4 includes architecture testing (from Pest 3): - - + +```php arch('controllers') ->expect('App\Http\Controllers') ->toExtendNothing() ->toHaveSuffix('Controller'); - - +``` ## Common Pitfalls diff --git a/.cursor/skills/socialite-development/SKILL.md b/.cursor/skills/socialite-development/SKILL.md new file mode 100644 index 000000000..e660da691 --- /dev/null +++ b/.cursor/skills/socialite-development/SKILL.md @@ -0,0 +1,80 @@ +--- +name: socialite-development +description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication." +license: MIT +metadata: + author: laravel +--- + +# Socialite Authentication + +## Documentation + +Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth). + +## Available Providers + +Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch` + +Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`. + +Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`. + +Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand. + +Community providers differ from built-in providers in the following ways: +- Installed via `composer require socialiteproviders/{name}` +- Must register via event listener — NOT auto-discovered like built-in providers +- Use `search-docs` for the registration pattern + +## Adding a Provider + +### 1. Configure the provider + +Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly. + +### 2. Create redirect and callback routes + +Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details. + +### 3. Authenticate and store the user + +In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`. + +### 4. Customize the redirect (optional) + +- `scopes()` — merge additional scopes with the provider's defaults +- `setScopes()` — replace all scopes entirely +- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google) +- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object. +- `stateless()` — for API/SPA contexts where session state is not maintained + +### 5. Verify + +1. Config key matches driver name exactly (check the list above for hyphenated names) +2. `client_id`, `client_secret`, and `redirect` are all present +3. Redirect URL matches what is registered in the provider's OAuth dashboard +4. Callback route handles denied grants (when user declines authorization) + +Use `search-docs` for complete code examples of each step. + +## Additional Features + +Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details. + +User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes` + +## Testing + +Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods. + +## Common Pitfalls + +- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails. +- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors. +- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely. +- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`. +- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol). +- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved. +- Community providers require event listener registration via `SocialiteWasCalled`. +- `user()` throws when the user declines authorization. Always handle denied grants. \ No newline at end of file diff --git a/.cursor/skills/tailwindcss-development/SKILL.md b/.cursor/skills/tailwindcss-development/SKILL.md index 12bd896bb..7c8e295e8 100644 --- a/.cursor/skills/tailwindcss-development/SKILL.md +++ b/.cursor/skills/tailwindcss-development/SKILL.md @@ -1,24 +1,13 @@ --- name: tailwindcss-development -description: >- - Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, - working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, - typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, - hero section, cards, buttons, or any visual/UI changes. +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel --- # Tailwind CSS Development -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - ## Documentation Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. @@ -38,22 +27,24 @@ ### CSS-First Configuration In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - + +```css @theme { --color-brand: oklch(0.72 0.11 178); } - +``` ### Import Syntax In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - + +```diff - @tailwind base; - @tailwind components; - @tailwind utilities; + @import "tailwindcss"; - +``` ### Replaced Utilities @@ -77,43 +68,47 @@ ## Spacing Use `gap` utilities instead of margins for spacing between siblings: - + +```html
Item 1
Item 2
-
+``` ## Dark Mode If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - + +```html
Content adapts to color scheme
-
+``` ## Common Patterns ### Flexbox Layout - + +```html
Left content
Right content
-
+``` ### Grid Layout - + +```html
Card 1
Card 2
Card 3
-
+``` ## Common Pitfalls diff --git a/.env.development.example b/.env.development.example index b0b15f324..594b89201 100644 --- a/.env.development.example +++ b/.env.development.example @@ -24,6 +24,10 @@ RAY_ENABLED=false # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false +# Enable Laravel Nightwatch monitoring +NIGHTWATCH_ENABLED=false +NIGHTWATCH_TOKEN= + # Selenium Driver URL for Dusk DUSK_DRIVER_URL=http://selenium:4444 diff --git a/.env.testing b/.env.testing new file mode 100644 index 000000000..2f79f3389 --- /dev/null +++ b/.env.testing @@ -0,0 +1,15 @@ +APP_ENV=testing +APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= +APP_DEBUG=true + +DB_CONNECTION=testing + +CACHE_DRIVER=array +SESSION_DRIVER=array +QUEUE_CONNECTION=sync +MAIL_MAILER=array +TELESCOPE_ENABLED=false + +REDIS_HOST=127.0.0.1 + +SELF_HOSTED=true diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml index 42df4785e..f0c77577e 100644 --- a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml @@ -9,9 +9,6 @@ body: > [!IMPORTANT] > **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.) - # 💎 Bounty Program (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new)) - - If you would like to prioritize the issue resolution, consider adding a bounty to this issue through our [Bounty Program](https://console.algora.io/org/coollabsio/bounties/new). - - type: textarea attributes: label: Error Message and Logs diff --git a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml deleted file mode 100644 index ef26125e0..000000000 --- a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: 💎 Enhancement Bounty -description: "Propose a new feature, service, or improvement with an attached bounty." -title: "[Enhancement]: " -labels: ["✨ Enhancement", "🔍 Triage"] -body: - - type: markdown - attributes: - value: | - > [!IMPORTANT] - > **This issue template is exclusively for proposing new features, services, or improvements with an attached bounty.** Enhancements without a bounty can be discussed in the appropriate category of [Github Discussions](https://github.com/coollabsio/coolify/discussions). - - # 💎 Add a Bounty (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new)) - - [Click here to add the required bounty](https://console.algora.io/org/coollabsio/bounties/new) - - - type: dropdown - attributes: - label: Request Type - description: Select the type of request you are making. - options: - - New Feature - - New Service - - Improvement - validations: - required: true - - - type: textarea - attributes: - label: Description - description: Provide a detailed description of the feature, improvement, or service you are proposing. - validations: - required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 157e409c8..e1286eb22 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,45 +1,51 @@ - + -### Changes - - +## Changes + + - -### Issues - +## Issues -- fixes: + -### Category - -- [x] Bug fix -- [x] New feature -- [x] Adding new one click service -- [x] Fixing or updating existing one click service +- Fixes -### Screenshots or Video (if applicable) - - +## Category -### AI Usage - - +- [ ] Bug fix +- [ ] Improvement +- [ ] New feature +- [ ] Adding new one click service +- [ ] Fixing or updating existing one click service -- [x] AI is used in the process of creating this PR -- [x] AI is NOT used in the process of creating this PR +## Preview -### Steps to Test - - + -- Step 1 – what to do first -- Step 2 – next action +## AI Assistance -### Contributor Agreement - + + +- [ ] AI was NOT used to create this PR +- [ ] AI was used (please describe below) + +**If AI was used:** + +- Tools used: +- How extensively: + +## Testing + + + +## Contributor Agreement + + > [!IMPORTANT] > -> - [x] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. -> - [x] I have tested the changes thoroughly and am confident that they will work as expected without issues when the maintainer tests them +> - [ ] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. +> - [ ] I have searched [existing issues](https://github.com/coollabsio/coolify/issues) and [pull requests](https://github.com/coollabsio/coolify/pulls) (including closed ones) to ensure this isn't a duplicate. +> - [ ] I have tested all the changes thoroughly with a local development instance of Coolify and I am confident that they will work as expected when a maintainer tests them. diff --git a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml deleted file mode 100644 index 8ac199a08..000000000 --- a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Remove Labels and Assignees on Issue Close - -on: - issues: - types: [closed] - pull_request: - types: [closed] - pull_request_target: - types: [closed] - -permissions: - issues: write - pull-requests: write - -jobs: - remove-labels-and-assignees: - runs-on: ubuntu-latest - steps: - - name: Remove labels and assignees - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - - async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) { - try { - if (isFromPR && prBaseBranch !== 'v4.x') { - return; - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: issueNumber - }); - - const labelsToKeep = currentLabels - .filter(label => label.name === '⏱︎ Stale') - .map(label => label.name); - - await github.rest.issues.setLabels({ - owner, - repo, - issue_number: issueNumber, - labels: labelsToKeep - }); - - const { data: issue } = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber - }); - - if (issue.assignees && issue.assignees.length > 0) { - await github.rest.issues.removeAssignees({ - owner, - repo, - issue_number: issueNumber, - assignees: issue.assignees.map(assignee => assignee.login) - }); - } - } catch (error) { - if (error.status !== 404) { - console.error(`Error processing issue ${issueNumber}:`, error); - } - } - } - - if (context.eventName === 'issues') { - await processIssue(context.payload.issue.number); - } - - if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { - const pr = context.payload.pull_request; - await processIssue(pr.number); - if (pr.merged && pr.base.ref === 'v4.x' && pr.body) { - const issueReferences = pr.body.match(/#(\d+)/g); - if (issueReferences) { - for (const reference of issueReferences) { - const issueNumber = parseInt(reference.substring(1)); - await processIssue(issueNumber, true, pr.base.ref); - } - } - } - } diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index 477274751..5ccb43a8e 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -8,6 +8,7 @@ on: - .github/workflows/coolify-helper-next.yml - .github/workflows/coolify-realtime.yml - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml - docker/coolify-helper/Dockerfile - docker/coolify-realtime/Dockerfile - docker/testing-host/Dockerfile diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 494ef6939..c5b70ca92 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -11,6 +11,7 @@ on: - .github/workflows/coolify-helper-next.yml - .github/workflows/coolify-realtime.yml - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml - docker/coolify-helper/Dockerfile - docker/coolify-realtime/Dockerfile - docker/testing-host/Dockerfile diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 935a88721..c02c13848 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -3,6 +3,12 @@ name: Generate Changelog on: push: branches: [ v4.x ] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml workflow_dispatch: permissions: diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml new file mode 100644 index 000000000..45a695ddc --- /dev/null +++ b/.github/workflows/pr-quality.yaml @@ -0,0 +1,111 @@ +name: PR Quality + +permissions: + contents: read + issues: read + pull-requests: write + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + pr-quality: + runs-on: ubuntu-latest + steps: + - uses: peakoss/anti-slop@v0 + with: + # General Settings + max-failures: 4 + + # PR Branch Checks + allowed-target-branches: "next" + blocked-target-branches: "" + allowed-source-branches: "" + blocked-source-branches: | + main + master + v4.x + + # PR Quality Checks + max-negative-reactions: 0 + require-maintainer-can-modify: true + + # PR Title Checks + require-conventional-title: true + + # PR Description Checks + require-description: true + max-description-length: 2500 + max-emoji-count: 2 + max-code-references: 5 + require-linked-issue: false + blocked-terms: | + STRAWBERRY + 🤖 Generated with Claude Code + Generated with Claude Code + blocked-issue-numbers: 8154 + + # PR Template Checks + require-pr-template: true + strict-pr-template-sections: "Contributor Agreement" + optional-pr-template-sections: "Issues,Preview" + max-additional-pr-template-sections: 2 + + # Commit Message Checks + max-commit-message-length: 500 + require-conventional-commits: false + require-commit-author-match: true + blocked-commit-authors: "" + + # File Checks + allowed-file-extensions: "" + allowed-paths: "" + blocked-paths: | + README.md + SECURITY.md + LICENSE + CODE_OF_CONDUCT.md + templates/service-templates-latest.json + templates/service-templates.json + require-final-newline: true + max-added-comments: 10 + + # User Checks + detect-spam-usernames: true + min-account-age: 30 + max-daily-forks: 7 + min-profile-completeness: 4 + + # Merge Checks + min-repo-merged-prs: 0 + min-repo-merge-ratio: 0 + min-global-merge-ratio: 30 + global-merge-ratio-exclude-own: false + + # Exemptions + exempt-draft-prs: false + exempt-bots: | + actions-user + dependabot[bot] + renovate[bot] + github-actions[bot] + exempt-users: "" + exempt-author-association: "OWNER,MEMBER,COLLABORATOR" + exempt-label: "quality/exempt" + exempt-pr-label: "" + exempt-all-milestones: false + exempt-all-pr-milestones: false + exempt-milestones: "" + exempt-pr-milestones: "" + + # PR Success Actions + success-add-pr-labels: "" + + # PR Failure Actions + failure-remove-pr-labels: "" + failure-remove-all-pr-labels: true + failure-add-pr-labels: "quality/rejected" + failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know." + close-pr: true + lock-pr: false diff --git a/.gitignore b/.gitignore index 935ea548e..403028761 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ docker/coolify-realtime/node_modules .DS_Store CHANGELOG.md /.workspaces +tests/Browser/Screenshots +tests/v4/Browser/Screenshots diff --git a/AGENTS.md b/AGENTS.md index 162c23842..3fff0074e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,14 +9,17 @@ ## 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.1 +- php - 8.5 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/horizon (HORIZON) - v5 +- laravel/nightwatch (NIGHTWATCH) - v1 +- laravel/pail (PAIL) - v1 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 - laravel/socialite (SOCIALITE) - v5 - livewire/livewire (LIVEWIRE) - v3 +- laravel/boost (BOOST) - v2 - laravel/dusk (DUSK) - v8 - laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 @@ -32,11 +35,15 @@ ## Skills Activation This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. -- `livewire-development` — Develops reactive Livewire 3 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. -- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. -- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. -- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. -- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application. +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching. +- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication. +- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. +- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. +- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features. +- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring. +- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application. ## Conventions @@ -69,76 +76,51 @@ ## Replies # Laravel Boost -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. ## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. -## URLs +## Tinker -- 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 trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. -- 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 - -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. +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === # PHP - Always use curly braces for control structures, even for single-line bodies. - -## Constructors - -- Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - -## 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 -{ - ... -} - - -## Enums - -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - -## Comments - -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. - -## PHPDoc Blocks - -- Add useful array shape type definitions when appropriate. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. === tests rules === @@ -151,47 +133,22 @@ # Test Enforcement # 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. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. - If you're creating a generic PHP class, use `php 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`. +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. -### APIs & Eloquent Resources +## 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. - -## 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. -## Queues - -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - -## 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. @@ -232,16 +189,15 @@ ### Models # Livewire -- Livewire allows you to build dynamic, reactive interfaces using only PHP — no JavaScript required. -- Instead of writing frontend code in JavaScript frameworks, you use Alpine.js to build the UI when client-side interactions are required. -- State lives on the server; the UI reflects it. Validate and authorize in actions (they're like HTTP requests). -- IMPORTANT: Activate `livewire-development` every time you're working with Livewire-related tasks. +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. === pint/core rules === # Laravel Pint Code Formatter -- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. - Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === @@ -251,22 +207,5 @@ ## Pest - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Do NOT delete tests without approval. -- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. -- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. -=== tailwindcss/core rules === - -# Tailwind CSS - -- Always use existing Tailwind conventions; check project patterns before adding new ones. -- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. -- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. - -=== laravel/fortify rules === - -# Laravel Fortify - -- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. -- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. -- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e8ae806..8cd7287f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1190,7 +1190,118 @@ ### 🚀 Features - *(service)* Update autobase to version 2.5 (#7923) - *(service)* Add chibisafe template (#5808) - *(ui)* Improve sidebar menu items styling (#7928) -- *(service)* Improve open-archiver +- *(template)* Add open archiver template (#6593) +- *(service)* Add linkding template (#6651) +- *(service)* Add glip template (#7937) +- *(templates)* Add Sessy docker compose template (#7951) +- *(api)* Add update urls support to services api +- *(api)* Improve service urls update +- *(api)* Add url update support to services api (#7929) +- *(api)* Improve docker_compose_domains +- *(api)* Add more allowed fields +- *(notifications)* Add mattermost notifications (#7963) +- *(templates)* Add ElectricSQL docker compose template +- *(service)* Add back soketi-app-manager +- *(service)* Upgrade checkmate to v3 (#7995) +- *(service)* Update pterodactyl version (#7981) +- *(service)* Add langflow template (#8006) +- *(service)* Upgrade listmonk to v6 +- *(service)* Add alexandrie template (#8021) +- *(service)* Upgrade formbricks to v4 (#8022) +- *(service)* Add goatcounter template (#8029) +- *(installer)* Add tencentos as a supported os +- *(installer)* Update nightly install script +- Update pr template to remove unnecessary quote blocks +- *(service)* Add satisfactory game server (#8056) +- *(service)* Disable mautic (#8088) +- *(service)* Add bento-pdf (#8095) +- *(ui)* Add official postgres 18 support +- *(database)* Add official postgres 18 support +- *(ui)* Use 2 column layout +- *(database)* Add official postgres 18 and pgvector 18 support (#8143) +- *(ui)* Improve global search with uuid and pr support (#7901) +- *(openclaw)* Add Openclaw service with environment variables and health checks +- *(service)* Disable maybe +- *(service)* Disable maybe (#8167) +- *(service)* Add sure +- *(service)* Add sure (#8157) +- *(docker)* Install PHP sockets extension in development environment +- *(services)* Add Spacebot service with custom logo support (#8427) +- Expose scheduled tasks to API +- *(api)* Add OpenAPI for managing scheduled tasks for applications and services +- *(api)* Add delete endpoints for scheduled tasks in applications and services +- *(api)* Add update endpoints for scheduled tasks in applications and services +- *(api)* Add scheduled tasks CRUD API with auth and validation (#8428) +- *(monitoring)* Add scheduled job monitoring dashboard (#8433) +- *(service)* Disable plane +- *(service)* Disable plane (#8580) +- *(service)* Disable pterodactyl panel and pterodactyl wings +- *(service)* Disable pterodactyl panel and pterodactyl wings (#8512) +- *(service)* Upgrade beszel and beszel-agent to v0.18 +- *(service)* Upgrade beszel and beszel-agent to v0.18 (#8513) +- Add command healthcheck type +- Require health check command for 'cmd' type with backend validation and frontend update +- *(healthchecks)* Add command health checks with input validation +- *(healthcheck)* Add command-based health check support (#8612) +- *(jobs)* Optimize async job dispatches and enhance Stripe subscription sync +- *(jobs)* Add queue delay resilience to scheduled job execution +- *(scheduler)* Add pagination to skipped jobs and filter manager start events +- Add comment field to environment variables +- Limit comment field to 256 characters for environment variables +- Enhance environment variable handling to support mixed formats and add comprehensive tests +- Add comment field to shared environment variables +- Show comment field for locked environment variables +- Add function to extract inline comments from docker-compose YAML environment variables +- Add magic variable detection and update UI behavior accordingly +- Add comprehensive environment variable parsing with nested resolution and hardcoded variable detection +- *(models)* Add is_required to EnvironmentVariable fillable array +- Add comment field to environment variables (#7269) +- *(service)* Pydio-cells.yml +- Pydio cells svg +- Pydio-cells.yml pin to stable version +- *(service)* Add Pydio cells (#8323) +- *(service)* Disable minio community edition +- *(service)* Disable minio community edition (#8686) +- *(subscription)* Add Stripe server limit quantity adjustment flow +- *(subscription)* Add refunds and cancellation management (#8637) +- Add configurable timeout for public database TCP proxy +- Add configurable proxy timeout for public database TCP proxy (#8673) +- *(jobs)* Implement encrypted queue jobs +- *(proxy)* Add database-backed config storage with disk backups +- *(proxy)* Add database-backed config storage with disk backups (#8905) +- *(livewire)* Add selectedActions parameter and error handling to delete methods +- *(gitlab)* Add GitLab source integration with SSH and HTTP basic auth +- *(git-sources)* Add GitLab integration and URL encode credentials (#8910) +- *(server)* Add server metadata collection and display +- *(git-import)* Support custom ssh command for fetch, submodule, and lfs +- *(ui)* Add log filter based on log level +- *(ui)* Add log filter based on log level (#8784) +- *(seeders)* Add GitHub deploy key example application +- *(service)* Update n8n-with-postgres-and-worker to 2.10.4 (#8807) +- *(service)* Add container label escape control to services API +- *(server)* Allow force deletion of servers with resources +- *(server)* Allow force deletion of servers with resources (#8962) +- *(compose-preview)* Populate fqdn from docker_compose_domains +- *(compose-preview)* Populate fqdn from docker_compose_domains (#8963) +- *(server)* Auto-fetch server metadata after validation +- *(server)* Auto-fetch server metadata after validation (#8964) +- *(templates)* Add imgcompress service, for offline image processing (#8763) +- *(service)* Add librespeed (#8626) +- *(service)* Update databasus to v3.16.2 (#8586) +- *(preview)* Add configurable PR suffix toggle for volumes +- *(api)* Add storages endpoints for applications +- *(api)* Expand update_storage to support name, mount_path, host_path, content fields +- *(environment-variable)* Add placeholder hint for magic variables +- *(subscription)* Display next billing date and billing interval +- *(api)* Support comments in bulk environment variable endpoints +- *(api)* Add database environment variable management endpoints +- *(storage)* Add resources tab and improve S3 deletion handling +- *(storage)* Group backups by database and filter by s3 status +- *(storage)* Add storage management for backup schedules +- *(jobs)* Add cache-based deduplication for delayed cron execution +- *(storage)* Add storage endpoints and UUID support for databases and services +- *(monitoring)* Add Laravel Nightwatch monitoring support +- *(validation)* Make hostname validation case-insensitive and expand allowed characters ### 🐛 Bug Fixes @@ -3773,6 +3884,7 @@ ### 🐛 Bug Fixes - *(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 +- *(git)* Tracking issue due to case sensitivity - *(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 @@ -3798,7 +3910,6 @@ ### 🐛 Bug Fixes - *(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) @@ -4422,6 +4533,197 @@ ### 🐛 Bug Fixes - *(api)* Deprecate applications compose endpoint - *(api)* Applications post and patch endpoints - *(api)* Applications create and patch endpoints (#7917) +- *(service)* Sftpgo port +- *(env)* Only cat .env file in dev +- *(api)* Encoding checks (#7944) +- *(env)* Only show nixpacks plan variables section in dev +- Switch custom labels check to UTF-8 +- *(api)* One click service name and description cannot be set during creation +- *(ui)* Improve volume mount warning for compose applications (#7947) +- *(api)* Show an error if the same 2 urls are provided +- *(preview)* Docker compose preview URLs (#7959) +- *(api)* Check domain conflicts within the request +- *(api)* Include docker_compose_domains in domain conflict check +- *(api)* Is_static and docker network missing +- *(api)* If domains field is empty clear the fqdn column +- *(api)* Application endpoint issues part 2 (#7948) +- Optimize queries and caching for projects and environments +- *(perf)* Eliminate N+1 queries from InstanceSettings and Server lookups (#7966) +- Update version numbers to 4.0.0-beta.462 and 4.0.0-beta.463 +- *(service)* Update seaweedfs logo (#7971) +- *(service)* Soju svg +- *(service)* Autobase database is not persisted correctly (#7978) +- *(ui)* Make tooltips a bit wider +- *(ui)* Modal issues +- *(validation)* Add @, / and & support to names and descriptions +- *(backup)* Postgres restore arithmetic syntax error (#7997) +- *(service)* Users unable to create their first ente account without SMTP (#7986) +- *(ui)* Horizontal overflow on application and service headings (#7970) +- *(service)* Supabase studio settings redirect loop (#7828) +- *(env)* Skip escaping for valid JSON in environment variables (#6160) +- *(service)* Disable kong response buffering and increase timeouts (#7864) +- *(service)* Rocketchat fails to start due to database version incompatibility (#7999) +- *(service)* N8n v2 with worker timeout error +- *(service)* Elasticsearch-with-kibana not generating account token +- *(service)* Elasticsearch-with-kibana not generating account token (#8067) +- *(service)* Kimai fails to start (#8027) +- *(service)* Reactive-resume template (#8048) +- *(api)* Infinite loop with github app with many repos (#8052) +- *(env)* Skip escaping for valid JSON in environment variables (#8080) +- *(docker)* Update PostgreSQL version to 16 in Dockerfile +- *(validation)* Enforce url validation for instance domain (#8078) +- *(service)* Bluesky pds invite code doesn't generate (#8081) +- *(service)* Bugsink login fails due to cors (#8083) +- *(service)* Strapi doesn't start (#8084) +- *(service)* Activepieces postgres 18 volume mount (#8098) +- *(service)* Forgejo login failure (#8145) +- *(database)* Pgvector 18 version is not parsed properly +- *(labels)* Make sure name is slugified +- *(parser)* Replace dashes and dots in auto generated envs +- Stop database proxy when is_public changes to false (#8138) +- *(docs)* Update documentation link for Openclaw service +- *(api-docs)* Use proper schema references for environment variable endpoints (#8239) +- *(ui)* Fix datalist border color and add repository selection watcher (#8240) +- *(server)* Improve IP uniqueness validation with team-specific error messages +- *(jobs)* Initialize status variable in checkHetznerStatus (#8359) +- *(jobs)* Handle queue timeouts gracefully in Horizon (#8360) +- *(push-server-job)* Skip containers with empty service subId (#8361) +- *(database)* Disable proxy on port allocation failure (#8362) +- *(sentry)* Use withScope for SSH retry event tracking (#8363) +- *(api)* Add a newline to openapi.json +- *(server)* Improve IP uniqueness validation with team-specific error messages +- *(service)* Glitchtip webdashboard doesn't load +- *(service)* Glitchtip webdashboard doesn't load (#8249) +- *(api)* Improve scheduled tasks API with auth, validation, and execution endpoints +- *(api)* Improve scheduled tasks validation and delete logic +- *(security)* Harden deployment paths and deploy abilities (#8549) +- *(service)* Always enable force https labels +- *(traefik)* Respect force https in service labels (#8550) +- *(team)* Include webhook notifications in enabled check (#8557) +- *(service)* Resolve team lookup via service relationship +- *(service)* Resolve team lookup via service relationship (#8559) +- *(database)* Chown redis/keydb configs when custom conf set (#8561) +- *(version)* Update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465 +- *(applications)* Treat zero private_key_id as deploy key (#8563) +- *(deploy)* Split BuildKit and secrets detection (#8565) +- *(auth)* Prevent CSRF redirect loop during 2FA challenge (#8596) +- *(input)* Prevent eye icon flash on password fields before Alpine.js loads (#8599) +- *(api)* Correct permission requirements for POST endpoints (#8600) +- *(health-checks)* Prevent command injection in health check commands (#8611) +- *(auth)* Prevent cross-tenant IDOR in resource cloning (#8613) +- *(docker)* Centralize command escaping in executeInDocker helper (#8615) +- *(api)* Add team authorization to domains_by_server endpoint (#8616) +- *(ca-cert)* Prevent command injection via base64 encoding (#8617) +- *(scheduler)* Add self-healing for stale Redis locks and detection in UI (#8618) +- *(health-checks)* Sanitize and validate CMD healthcheck commands +- *(healthchecks)* Remove redundant newline sanitization from CMD healthcheck +- *(soketi)* Make host binding configurable for IPv6 support (#8619) +- *(ssh)* Automatically fix SSH directory permissions during upgrade (#8635) +- *(jobs)* Prevent non-due jobs firing on restart and enrich skip logs with resource links +- *(database)* Close confirmation modal after import/restore +- Application rollback uses correct commit sha +- *(rollback)* Escape commit SHA to prevent shell injection +- Save comment field when creating application environment variables +- Allow editing comments on locked environment variables +- Add Update button for locked environment variable comments +- Remove duplicate delete button from locked environment variable view +- Position Update button next to comment field for locked variables +- Preserve existing comments in bulk update and always show save notification +- Update success message logic to only show when changes are made +- *(bootstrap)* Add bounds check to extractBalancedBraceContent +- Pydio-cells svg path typo +- *(database)* Handle PDO constant name change for PGSQL_ATTR_DISABLE_PREPARES +- *(proxy)* Handle IPv6 CIDR notation in Docker network gateways (#8703) +- *(ssh)* Prevent RCE via SSH command injection (#8748) +- *(service)* Cloudreve doesn't persist data across restarts +- *(service)* Cloudreve doesn't persist data across restarts (#8740) +- Join link should be set correctly in the env variables +- *(service)* Ente photos join link doesn't work (#8727) +- *(subscription)* Harden quantity updates and proxy trust behavior +- *(auth)* Resolve 419 session errors with domain-based access and Cloudflare Tunnels (#8749) +- *(server)* Handle limit edge case and IPv6 allowlist dedupe +- *(server-limit)* Re-enable force-disabled servers at limit +- *(ip-allowlist)* Add IPv6 CIDR support for API access restrictions (#8750) +- *(proxy)* Remove ipv6 cidr network remediation +- Address review feedback on proxy timeout +- *(proxy)* Add validation and normalization for database proxy timeout +- *(proxy)* Mounting error for nginx.conf in dev +- Enable preview deployment page for deploy key applications +- *(application-source)* Support localhost key with id=0 +- Enable preview deployment page for deploy key applications (#8579) +- *(docker-compose)* Respect preserveRepository setting when executing start command (#8848) +- *(proxy)* Mounting error for nginx.conf in dev (#8662) +- *(database)* Close confirmation modal after database import/restore (#8697) +- *(subscription)* Use optional chaining for preview object access +- *(parser)* Use firstOrCreate instead of updateOrCreate for environment variables +- *(env-parser)* Capture clean variable names without trailing braces in bash-style defaults (#8855) +- *(terminal)* Resolve WebSocket connection and host authorization issues (#8862) +- *(docker-cleanup)* Respect keep for rollback setting for Nixpacks build images (#8859) +- *(push-server)* Track last_online_at and reset database restart state +- *(docker)* Prevent false container exits on failed docker queries (#8860) +- *(api)* Require write permission for validation endpoints +- *(sentinel)* Add token validation to prevent command injection +- *(log-drain)* Prevent command injection by base64-encoding environment variables +- *(git-ref-validation)* Prevent command injection via git references +- *(docker)* Add path validation to prevent command injection in file locations +- Prevent command injection and fix developer view shared variables error (#8889) +- Build-time environment variables break Next.js (#8890) +- *(modal)* Make confirmation modal close after dispatching Livewire actions (#8892) +- *(parser)* Preserve user-saved env vars on Docker Compose redeploy (#8894) +- *(security)* Sanitize newlines in health check commands to prevent RCE (#8898) +- Prevent scheduled task input fields from losing focus +- Prevent scheduled task input fields from losing focus (#8654) +- *(api)* Add docker_cleanup parameter to stop endpoints +- *(api)* Add docker_cleanup parameter to stop endpoints (#8899) +- *(deployment)* Filter null and empty environment variables from nixpacks plan +- *(deployment)* Filter null and empty environment variables from nixpacks plan (#8902) +- *(livewire)* Add error handling and selectedActions to delete methods (#8909) +- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables +- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables (#8915) +- *(ssh)* Remove undefined trackSshRetryEvent() method call (#8927) +- *(validation)* Support scoped packages in file path validation (#8928) +- *(parsers)* Resolve shared variables in compose environment +- *(parsers)* Resolve shared variables in compose environment (#8930) +- *(api)* Cast teamId to int in deployment authorization check +- *(api)* Cast teamId to int in deployment authorization check (#8931) +- *(git-import)* Ensure ssh key is used for fetch, submodule, and lfs operations (#8933) +- *(ui)* Info logs were not highlighted with blue color +- *(application)* Clarify deployment type precedence logic +- *(git-import)* Explicitly specify ssh key and remove duplicate validation rules +- *(application)* Clarify deployment type precedence logic (#8934) +- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain +- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain (#8948) +- *(service)* Hoppscotch fails to start due to db unhealthy +- *(service)* Hoppscotch fails to start due to db unhealthy (#8949) +- *(api)* Allow is_container_label_escape_enabled in service operations (#8955) +- *(docker-compose)* Respect preserveRepository when injecting --project-directory +- *(docker-compose)* Respect preserveRepository when injecting --project-directory (#8956) +- *(compose)* Include git branch in compose file not found error +- *(template)* Fix heyform template +- *(template)* Fix heyform template (#8747) +- *(preview)* Exclude bind mounts from preview deployment suffix +- *(preview)* Sync isPreviewSuffixEnabled property on file storage save +- *(storages)* Hide PR suffix for services and fix instantSave logic +- *(preview)* Enable per-volume control of PR suffix in preview deployments (#9006) +- Prevent sporadic SSH permission denied by validating key content +- *(ssh)* Handle chmod failures gracefully and simplify key management +- Prevent sporadic SSH permission denied on key rotation (#8990) +- *(stripe)* Add error handling and resilience to subscription operations +- *(stripe)* Add error handling and resilience to subscription operations (#9030) +- *(api)* Extract resource UUIDs from route parameters +- *(backup)* Throw explicit error when S3 storage missing or deleted (#9038) +- *(docker)* Skip cleanup stale warning on cloud instances +- *(deployment)* Disable build server during restart operations +- *(deployment)* Disable build server during restart operations (#9045) +- *(docker)* Log failed cleanup attempts when server is not functional +- *(environment-variable)* Guard refresh against missing or stale variables +- *(github-webhook)* Handle unsupported event types gracefully +- *(github-webhook)* Handle unsupported event types gracefully (#9119) +- *(deployment)* Properly escape shell arguments in nixpacks commands +- *(deployment)* Properly escape shell arguments in nixpacks commands (#9122) +- *(validation)* Make hostname validation case-insensitive and expand allowed name characters (#9134) +- *(team)* Resolve server limit checks for API token authentication (#9123) +- *(subscription)* Prevent duplicate subscriptions with updateOrCreate ### 💼 Other @@ -4886,6 +5188,12 @@ ### 💼 Other - CVE-2025-55182 React2shell infected supabase/studio:2025.06.02-sha-8f2993d - Bump superset to 6.0.0 - Trim whitespace from domain input in instance settings (#7837) +- Upgrade postgres client to fix build error +- Application rollback uses correct commit sha (#8576) +- *(deps)* Bump rollup from 4.57.1 to 4.59.0 +- *(deps)* Bump rollup from 4.57.1 to 4.59.0 (#8691) +- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1 +- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1 (#8793) ### 🚜 Refactor @@ -5510,6 +5818,23 @@ ### 🚜 Refactor - Move all env sorting to one place - *(api)* Make docker_compose_raw description more clear - *(api)* Update application create endpoints docs +- *(api)* Application urls validation +- *(services)* Improve some service slogans +- *(ssh-retry)* Remove Sentry tracking from retry logic +- *(ssh-retry)* Remove Sentry tracking from retry logic +- *(jobs)* Split task skip checks into critical and runtime phases +- Add explicit fillable array to EnvironmentVariable model +- Replace inline note with callout component for consistency +- *(application-source)* Use Laravel helpers for null checks +- *(ssh)* Remove Sentry retry event tracking from ExecuteRemoteCommand +- Consolidate file path validation patterns and support scoped packages +- *(environment-variable)* Remove buildtime/runtime options and improve comment field +- Remove verbose logging and use explicit exception types +- *(breadcrumb)* Optimize queries and simplify state management +- *(scheduler)* Extract cron scheduling logic to shared helper +- *(team)* Make server limit methods accept optional team parameter +- *(team)* Update serverOverflow to use static serverLimit +- *(docker)* Simplify installation and remove version pinning ### 📚 Documentation @@ -5616,7 +5941,6 @@ ### 📚 Documentation - Update changelog - *(tests)* Update testing guidelines for unit and feature tests - *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references -- Update changelog - *(database-patterns)* Add critical note on mass assignment protection for new columns - Clarify cloud-init script compatibility - Update changelog @@ -5647,7 +5971,27 @@ ### 📚 Documentation - Update application architecture and database patterns for request-level caching best practices - Remove git worktree symlink instructions from CLAUDE.md - Remove git worktree symlink instructions from CLAUDE.md (#7908) +- Add transcript lol link and logo to readme (#7331) +- *(api)* Change domains to urls +- *(api)* Improve domains API docs - Update changelog +- Update changelog +- *(api)* Improve app endpoint deprecation description +- Add Coolify design system reference +- Add Coolify design system reference (#8237) +- Update changelog +- Update changelog +- Update changelog +- *(sponsors)* Add huge sponsors section and reorganize list +- *(application)* Add comments explaining commit selection logic for rollback support +- *(readme)* Add VPSDime to Big Sponsors list +- *(readme)* Move MVPS to Huge Sponsors section +- *(settings)* Clarify Do Not Track helper text +- Update changelog +- Update changelog +- *(sponsors)* Add ScreenshotOne as a huge sponsor +- *(sponsors)* Update Brand.dev to Context.dev +- *(readme)* Add PetroSky Cloud to sponsors ### ⚡ Performance @@ -5658,6 +6002,7 @@ ### ⚡ Performance - Remove dead server filtering code from Kernel scheduler (#7585) - *(server)* Optimize destinationsByServer query - *(server)* Optimize destinationsByServer query (#7854) +- *(breadcrumb)* Optimize queries and simplify navigation to fix OOM (#9048) ### 🎨 Styling @@ -5670,6 +6015,7 @@ ### 🎨 Styling - *(campfire)* Format environment variables for better readability in Docker Compose file - *(campfire)* Update comment for DISABLE_SSL environment variable for clarity - Update background colors to use gray-50 for consistency in auth views +- *(modal-confirmation)* Improve mobile responsiveness ### 🧪 Testing @@ -5686,6 +6032,14 @@ ### 🧪 Testing - Add tests for shared environment variable spacing and resolution - Add comprehensive preview deployment port and path tests - Add comprehensive preview deployment port and path tests (#7677) +- Add Pest browser testing with SQLite :memory: schema +- Add dashboard test and improve browser test coverage +- Migrate to SQLite :memory: and add Pest browser testing (#8364) +- *(rollback)* Use full-length git commit SHA values in test fixtures +- *(rollback)* Verify shell metacharacter escaping in git commit parameter +- *(factories)* Add missing model factories for app test suite +- *(magic-variables)* Add feature tests for SERVICE_URL/FQDN variable handling +- Add behavioral ssh key stale-file regression ### ⚙️ Miscellaneous Tasks @@ -6293,10 +6647,10 @@ ### ⚙️ Miscellaneous Tasks - *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files - *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively - *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively -- *(service)* Update Nitropage template (#6181) -- *(versions)* Update all version - *(bump)* Update composer deps - *(version)* Bump Coolify version to 4.0.0-beta.420.6 +- *(service)* Update Nitropage template (#6181) +- *(versions)* Update all version - *(service)* Improve matrix service - *(service)* Format runner service - *(service)* Improve sequin @@ -6399,6 +6753,94 @@ ### ⚙️ Miscellaneous Tasks - *(services)* Upgrade service template json files - *(api)* Update openapi json and yaml - *(api)* Regenerate openapi docs +- Prepare for PR +- *(api)* Improve current request error message +- *(api)* Improve current request error message +- *(api)* Update openapi files +- *(service)* Update service templates json +- *(services)* Update service template json files +- *(service)* Use major version for openpanel (#8053) +- Prepare for PR +- *(services)* Update service template json files +- Bump coolify version +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(scheduler)* Fix scheduled job duration metric (#8551) +- Prepare for PR +- Prepare for PR +- *(horizon)* Make max time configurable (#8560) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(ui)* Widen project heading nav spacing (#8564) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Add pr quality check workflow +- Do not build or generate changelog on pr-quality changes +- Add pr quality check via anti slop action (#8344) +- Improve pr quality workflow +- Delete label removal workflow +- Improve pr quality workflow (#8374) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(repo)* Improve contributor PR template +- Add anti-slop v0.2 options to the pr-quality check +- Improve pr template and quality check workflow (#8574) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(ui)* Add labels header +- *(ui)* Add container labels header (#8752) +- *(templates)* Update n8n templates to 2.10.2 (#8679) +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(version)* Bump coolify, realtime, and sentinel versions +- *(realtime)* Upgrade npm dependencies +- *(realtime)* Upgrade coolify-realtime to 1.0.11 +- Prepare for PR +- Prepare for PR +- Prepare for PR +- *(release)* Bump version to 4.0.0-beta.466 +- Prepare for PR +- Prepare for PR +- *(service)* Pin castopod service to a static version instead of latest +- *(service)* Remove unused attributes on imgcompress service +- *(service)* Pin imgcompress to a static version instead of latest +- *(service)* Update SeaweedFS images to version 4.13 (#8738) +- *(templates)* Bump databasus image version +- Remove coolify-examples-1 submodule +- *(versions)* Bump coolify, sentinel, and traefik versions +- *(versions)* Bump sentinel to 0.0.21 +- *(service)* Disable Booklore service (#9105) ### ◀️ Revert diff --git a/CLAUDE.md b/CLAUDE.md index 8e398586b..bb65da405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,14 +37,33 @@ # Frontend ## Architecture ### Backend Structure (app/) -- **Actions/** — Domain actions organized by area (Application, Database, Docker, Proxy, Server, Service, Shared, Stripe, User). Uses `lorisleiva/laravel-actions`. -- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Notifications, etc. This is the primary UI layer — no traditional Blade controllers. -- **Jobs/** — Queue jobs for deployments (`ApplicationDeploymentJob`), backups, Docker cleanup, server management, proxy configuration. -- **Models/** — Eloquent models. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). -- **Services/** — Business logic services. -- **Helpers/** — Global helper functions loaded via `bootstrap/includeHelpers.php`. -- **Data/** — Spatie Laravel Data DTOs. -- **Enums/** — PHP enums (TitleCase keys). +- **Actions/** — Domain actions organized by area (Application, Database, Docker, Proxy, Server, Service, Shared, Stripe, User, CoolifyTask, Fortify). Uses `lorisleiva/laravel-actions` with `AsAction` trait — actions can be called as objects, dispatched as jobs, or used as controllers. +- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Security, Notifications, Terminal, Subscription, SharedVariables. This is the primary UI layer — no traditional Blade controllers. Components listen to private team channels for real-time status updates via Soketi. +- **Jobs/** — Queue jobs for deployments (`ApplicationDeploymentJob`), backups, Docker cleanup, server management, proxy configuration. Uses Redis queue with Horizon for monitoring. +- **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`. +- **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations. +- **Helpers/** — Global helpers loaded via `bootstrap/includeHelpers.php` from `bootstrap/helpers/` — organized into `shared.php`, `constants.php`, `versions.php`, `subscriptions.php`, `domains.php`, `docker.php`, `services.php`, `github.php`, `proxy.php`, `notifications.php`. +- **Data/** — Spatie Laravel Data DTOs (e.g., `ServerMetadata`). +- **Enums/** — PHP enums (TitleCase keys). Key enums: `ProcessStatus`, `Role` (MEMBER/ADMIN/OWNER with rank comparison), `BuildPackTypes`, `ProxyTypes`, `ContainerStatusTypes`. +- **Rules/** — Custom validation rules (`ValidGitRepositoryUrl`, `ValidServerIp`, `ValidHostname`, `DockerImageFormat`, etc.). + +### API Layer +- REST API at `/api/v1/` with OpenAPI 3.0 attributes (`use OpenApi\Attributes as OA`) for auto-generated docs +- Authentication via Laravel Sanctum with custom `ApiAbility` middleware for token abilities (read, write, deploy) +- `ApiSensitiveData` middleware masks sensitive fields (IDs, credentials) in responses +- API controllers in `app/Http/Controllers/Api/` use inline `Validator` (not Form Request classes) +- Response serialization via `serializeApiResponse()` helper + +### Authorization +- Policy-based authorization with ~15 model-to-policy mappings in `AuthServiceProvider` +- Custom gates: `createAnyResource`, `canAccessTerminal` +- Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods +- Multi-tenancy via Teams — team auto-initializes notification settings on creation + +### Event Broadcasting +- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) +- Status change events: `ApplicationStatusChanged`, `ServiceStatusChanged`, `DatabaseStatusChanged`, `ProxyStatusChanged` +- Livewire components subscribe to private team channels via `getListeners()` ### Key Domain Concepts - **Server** — A managed host connected via SSH. Has settings, proxy config, and destinations. @@ -61,7 +80,7 @@ ### Frontend - Vite for asset bundling ### Laravel 10 Structure (NOT Laravel 11+ slim structure) -- Middleware in `app/Http/Middleware/` +- Middleware in `app/Http/Middleware/` — custom middleware includes `CheckForcePasswordReset`, `DecideWhatToDoWithUser`, `ApiAbility`, `ApiSensitiveData` - Kernels: `app/Http/Kernel.php`, `app/Console/Kernel.php` - Exception handler: `app/Exceptions/Handler.php` - Service providers in `app/Providers/` @@ -71,9 +90,9 @@ ## Key Conventions - Use `php artisan make:*` commands with `--no-interaction` to create files - Use Eloquent relationships, avoid `DB::` facade — prefer `Model::query()` - PHP 8.4: constructor property promotion, explicit return types, type hints -- Always create Form Request classes for validation +- Validation uses inline `Validator` facade in controllers/Livewire components and custom rules in `app/Rules/` — not Form Request classes - Run `vendor/bin/pint --dirty --format agent` before finalizing changes -- Every change must have tests — write or update tests, then run them +- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below) - Check sibling files for conventions before creating new files ## Git Workflow @@ -93,14 +112,17 @@ ## 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.1 +- php - 8.5 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/horizon (HORIZON) - v5 +- laravel/nightwatch (NIGHTWATCH) - v1 +- laravel/pail (PAIL) - v1 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 - laravel/socialite (SOCIALITE) - v5 - livewire/livewire (LIVEWIRE) - v3 +- laravel/boost (BOOST) - v2 - laravel/dusk (DUSK) - v8 - laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 @@ -116,11 +138,15 @@ ## Skills Activation This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. -- `livewire-development` — Develops reactive Livewire 3 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. -- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. -- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. -- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. -- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application. +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching. +- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication. +- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. +- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. +- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features. +- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring. +- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application. ## Conventions @@ -153,76 +179,51 @@ ## Replies # Laravel Boost -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. ## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. -## URLs +## Tinker -- 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 trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. -- 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 - -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. +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === # PHP - Always use curly braces for control structures, even for single-line bodies. - -## Constructors - -- Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - -## 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 -{ - ... -} - - -## Enums - -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - -## Comments - -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. - -## PHPDoc Blocks - -- Add useful array shape type definitions when appropriate. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. === tests rules === @@ -235,47 +236,22 @@ # Test Enforcement # 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. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. - If you're creating a generic PHP class, use `php 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`. +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. -### APIs & Eloquent Resources +## 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. - -## 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. -## Queues - -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - -## 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. @@ -316,16 +292,15 @@ ### Models # Livewire -- Livewire allows you to build dynamic, reactive interfaces using only PHP — no JavaScript required. -- Instead of writing frontend code in JavaScript frameworks, you use Alpine.js to build the UI when client-side interactions are required. -- State lives on the server; the UI reflects it. Validate and authorize in actions (they're like HTTP requests). -- IMPORTANT: Activate `livewire-development` every time you're working with Livewire-related tasks. +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. === pint/core rules === # Laravel Pint Code Formatter -- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. - Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === @@ -335,22 +310,5 @@ ## Pest - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Do NOT delete tests without approval. -- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. -- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. -=== tailwindcss/core rules === - -# Tailwind CSS - -- Always use existing Tailwind conventions; check project patterns before adding new ones. -- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. -- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. - -=== laravel/fortify rules === - -# Laravel Fortify - -- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. -- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. -- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9aec08420..85fceb28f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -212,7 +212,7 @@ #### Review Process - Duplicate or superseded work - Security or quality concerns -#### Code Quality, Testing, and Bounty Submissions +#### Code Quality and Testing All contributions must adhere to the highest standards of code quality and testing: - **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged. @@ -220,15 +220,6 @@ #### Code Quality, Testing, and Bounty Submissions - **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase. - **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately. -**For PRs that claim bounties:** - -- **Eligibility**: Bounty PRs must strictly follow all guidelines above. Untested, poorly described, or non-compliant PRs will not qualify for bounty rewards. -- **Original Work**: Bounties are for genuine contributions. Submitting AI-generated or copied code solely for bounty claims will result in disqualification and potential removal from contributing. -- **Quality Standards**: Bounty submissions are held to even higher standards. Ensure comprehensive testing, clear documentation, and alignment with project goals. When maintainers review the changes, they should work as expected (the things mentioned in the PR description plus what the bounty issuer needs). -- **Claim Process**: Only successfully merged PRs that pass all reviews (core maintainers + bounty issuer) and meet bounty criteria will be awarded. Follow the issue's bounty guidelines precisely. -- **Prioritization**: Contributor PRs are prioritized over first-time or new contributors. -- **Developer Experience**: We highly advise beginners to avoid participating in bug bounties for our codebase. Most of the time, they don't know what they are changing, how it affects other parts of the system, or if their changes are even correct. -- **Review Comments**: When maintainers ask questions, you should be able to respond properly without generic or AI-generated fluff. ## Development Notes diff --git a/README.md b/README.md index 276ef07b5..9a5feff4e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Coolify An open-source & self-hostable Heroku / Netlify / Vercel alternative. ![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=coolify.v4.version&style=for-the-badge -) [![Bounty Issues](https://img.shields.io/static/v1?labelColor=grey&color=6366f1&label=Algora&message=%F0%9F%92%8E+Bounty+issues&style=for-the-badge)](https://console.algora.io/org/coollabsio/bounties/new) +) ## About the Project @@ -55,24 +55,32 @@ ## Donations Thank you so much! +### Huge Sponsors + +* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality +* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs +* + ### Big Sponsors * [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! -* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform * [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services * [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain +* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy -* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration +* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design +* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions +* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions * [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers @@ -81,6 +89,7 @@ ### Big Sponsors * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting * [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers @@ -90,6 +99,7 @@ ### Big Sponsors * [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform * [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform * [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform +* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions ### Small Sponsors @@ -126,7 +136,6 @@ ### Small Sponsors RunPod DartNode Tyler Whitesides -SerpAPI Aquarela Crypto Jobs List Alfred Nutile diff --git a/app/Actions/CoolifyTask/PrepareCoolifyTask.php b/app/Actions/CoolifyTask/PrepareCoolifyTask.php deleted file mode 100644 index 3f76a2e3c..000000000 --- a/app/Actions/CoolifyTask/PrepareCoolifyTask.php +++ /dev/null @@ -1,54 +0,0 @@ -remoteProcessArgs = $remoteProcessArgs; - - if ($remoteProcessArgs->model) { - $properties = $remoteProcessArgs->toArray(); - unset($properties['model']); - - $this->activity = activity() - ->withProperties($properties) - ->performedOn($remoteProcessArgs->model) - ->event($remoteProcessArgs->type) - ->log('[]'); - } else { - $this->activity = activity() - ->withProperties($remoteProcessArgs->toArray()) - ->event($remoteProcessArgs->type) - ->log('[]'); - } - } - - public function __invoke(): Activity - { - $job = new CoolifyTask( - activity: $this->activity, - ignore_errors: $this->remoteProcessArgs->ignore_errors, - call_event_on_finish: $this->remoteProcessArgs->call_event_on_finish, - call_event_data: $this->remoteProcessArgs->call_event_data, - ); - dispatch($job); - $this->activity->refresh(); - - return $this->activity; - } -} diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c634f14ba..fa39f7909 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -51,9 +51,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } $configuration_dir = database_proxy_dir($database->uuid); + $host_configuration_dir = $configuration_dir; if (isDev()) { - $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; + $host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; } + $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); $nginxconf = <<public_port; proxy_pass $containerName:$internalPort; + $timeoutConfig } } EOF; @@ -85,7 +88,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St 'volumes' => [ [ 'type' => 'bind', - 'source' => "$configuration_dir/nginx.conf", + 'source' => "$host_configuration_dir/nginx.conf", 'target' => '/etc/nginx/nginx.conf', ], ], @@ -112,12 +115,61 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); - instant_remote_process([ - "mkdir -p $configuration_dir", - "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", - "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", - "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + + try { + instant_remote_process([ + "mkdir -p $configuration_dir", + "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", + "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", + "docker compose --project-directory {$configuration_dir} pull", + "docker compose --project-directory {$configuration_dir} up -d", + ], $server); + } catch (\RuntimeException $e) { + if ($this->isNonTransientError($e->getMessage())) { + $database->update(['is_public' => false]); + + $team = data_get($database, 'environment.project.team') + ?? data_get($database, 'service.environment.project.team'); + + $team?->notify( + new \App\Notifications\Container\ContainerRestarted( + "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", + $server, + ) + ); + + ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); + + return; + } + + throw $e; + } + } + + private function isNonTransientError(string $message): bool + { + $nonTransientPatterns = [ + 'port is already allocated', + 'address already in use', + 'Bind for', + ]; + + foreach ($nonTransientPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; + } + + private function buildProxyTimeoutConfig(?int $timeout): string + { + if ($timeout === null || $timeout < 1) { + $timeout = 3600; + } + + return "proxy_timeout {$timeout}s;"; } } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 58f3cda4e..fe80a7d54 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -207,6 +207,9 @@ public function handle(StandaloneKeydb $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 4e4f3ce53..70df91054 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -204,6 +204,9 @@ public function handle(StandaloneRedis $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 6c9a54f77..5966876c6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -327,6 +327,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($exitedService->status)->startsWith('exited')) { continue; } + + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; + } + $name = data_get($exitedService, 'name'); $fqdn = data_get($exitedService, 'fqdn'); if ($name) { @@ -406,6 +412,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($database->status)->startsWith('exited')) { continue; } + + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; + } + // Reset restart tracking when database exits completely $database->update([ 'status' => 'exited', diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 9f97dd0d4..7ea6a871e 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -37,12 +37,13 @@ public function create(array $input): User if (User::count() == 0) { // If this is the first user, make them the root user // Team is already created in the database/seeders/ProductionSeeder.php - $user = User::create([ + $user = (new User)->forceFill([ 'id' => 0, 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); + $user->save(); $team = $user->teams()->first(); // Disable registration after first user is created diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php index 158996c90..5baa8b7ed 100644 --- a/app/Actions/Fortify/ResetUserPassword.php +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -21,7 +21,7 @@ public function reset(User $user, array $input): void 'password' => ['required', Password::defaults(), 'confirmed'], ])->validate(); - $user->forceFill([ + $user->fill([ 'password' => Hash::make($input['password']), ])->save(); $user->deleteAllSessions(); diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php index 0c51ec56d..320eede0b 100644 --- a/app/Actions/Fortify/UpdateUserPassword.php +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -24,7 +24,7 @@ public function update(User $user, array $input): void 'current_password.current_password' => __('The provided password does not match your current password.'), ])->validateWithBag('updatePassword'); - $user->forceFill([ + $user->fill([ 'password' => Hash::make($input['password']), ])->save(); } diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index c8bfd930a..76c6c0736 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -35,7 +35,7 @@ public function update(User $user, array $input): void ) { $this->updateVerifiedUser($user, $input); } else { - $user->forceFill([ + $user->fill([ 'name' => $input['name'], 'email' => $input['email'], ])->save(); @@ -49,7 +49,7 @@ public function update(User $user, array $input): void */ protected function updateVerifiedUser(User $user, array $input): void { - $user->forceFill([ + $user->fill([ 'name' => $input['name'], 'email' => $input['email'], 'email_verified_at' => null, diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3aa1d8d34..159f12252 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -2,9 +2,12 @@ namespace App\Actions\Proxy; +use App\Enums\ProxyTypes; use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; +use Symfony\Component\Yaml\Yaml; class GetProxyConfiguration { @@ -17,28 +20,42 @@ public function handle(Server $server, bool $forceRegenerate = false): string 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); + // Primary source: database + $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + + // Validate stored config matches current proxy type + if (! empty(trim($proxy_configuration ?? ''))) { + if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) { + Log::warning('Stored proxy config does not match current proxy type, will regenerate', [ + 'server_id' => $server->id, + 'proxy_type' => $proxyType, + ]); + $proxy_configuration = null; + } + } + + // Backfill: existing servers may not have DB config yet — read from disk once + if (empty(trim($proxy_configuration ?? ''))) { + $proxy_configuration = $this->backfillFromDisk($server); + } } - // Generate default configuration if: - // 1. Force regenerate is requested - // 2. Configuration file doesn't exist or is empty + // Generate default configuration as last resort if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - // Extract custom commands from existing config before regenerating $custom_commands = []; if (! empty(trim($proxy_configuration ?? ''))) { $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); } + Log::warning('Proxy configuration regenerated to defaults', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found', + ]); + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } @@ -50,4 +67,53 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + + /** + * Check that the stored docker-compose YAML contains the expected service + * for the server's current proxy type. Returns false if the config belongs + * to a different proxy type (e.g. Traefik config on a CADDY server). + */ + private function configMatchesProxyType(string $proxyType, string $configuration): bool + { + try { + $yaml = Yaml::parse($configuration); + $services = data_get($yaml, 'services', []); + + return match ($proxyType) { + ProxyTypes::TRAEFIK->value => isset($services['traefik']), + ProxyTypes::CADDY->value => isset($services['caddy']), + ProxyTypes::NGINX->value => isset($services['nginx']), + default => true, + }; + } catch (\Throwable $e) { + // If YAML is unparseable, don't block — let the existing flow handle it + return true; + } + } + + /** + * Backfill: read config from disk for servers that predate DB storage. + * Stores the result in the database so future reads skip SSH entirely. + */ + private function backfillFromDisk(Server $server): ?string + { + $proxy_path = $server->proxyPath(); + $result = instant_remote_process([ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ], $server, false); + + if (! empty(trim($result ?? ''))) { + $server->proxy->last_saved_proxy_configuration = $result; + $server->save(); + + Log::info('Proxy config backfilled to database from disk', [ + 'server_id' => $server->id, + ]); + + return $result; + } + + return null; + } } diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php index 53fbecce2..bcfd5011d 100644 --- a/app/Actions/Proxy/SaveProxyConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -9,19 +9,41 @@ class SaveProxyConfiguration { use AsAction; + private const MAX_BACKUPS = 10; + public function handle(Server $server, string $configuration): void { $proxy_path = $server->proxyPath(); $docker_compose_yml_base64 = base64_encode($configuration); + $new_hash = str($docker_compose_yml_base64)->pipe('md5')->value; - // Update the saved settings hash - $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; + // Only create a backup if the configuration actually changed + $old_hash = $server->proxy->get('last_saved_settings'); + $config_changed = $old_hash && $old_hash !== $new_hash; + + // Update the saved settings hash and store full config as database backup + $server->proxy->last_saved_settings = $new_hash; + $server->proxy->last_saved_proxy_configuration = $configuration; $server->save(); - // 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); + $backup_path = "$proxy_path/backups"; + + // Transfer the configuration file to the server, with backup if changed + $commands = ["mkdir -p $proxy_path"]; + + if ($config_changed) { + $short_hash = substr($old_hash, 0, 8); + $timestamp = now()->format('Y-m-d_H-i-s'); + $backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml"; + $commands[] = "mkdir -p $backup_path"; + // Skip backup if a file with the same hash already exists (identical content) + $commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true"; + // Prune old backups, keep only the most recent ones + $commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true'; + } + + $commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null"; + + instant_remote_process($commands, $server); } } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 65a41db18..0d9ca0153 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -177,9 +177,10 @@ private function cleanupApplicationImages(Server $server, $applications = null): ->filter(fn ($image) => ! empty($image['tag'])); // Separate images into categories - // PR images (pr-*) and build images (*-build) are excluded from retention - // Build images will be cleaned up by docker image prune -af + // PR images (pr-*) are always deleted + // Build images (*-build) are cleaned up to match retained regular images $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); // Always delete all PR images @@ -209,6 +210,26 @@ private function cleanupApplicationImages(Server $server, $applications = null): 'output' => $deleteOutput ?? 'Image removed or was in use', ]; } + + // Clean up build images (-build suffix) that don't correspond to retained regular images + // Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers. + // If a build is in progress, docker rmi will fail silently since the image is in use. + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + foreach ($buildImages as $image) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + if (! $keptTags->contains($baseTag)) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Build image removed or was in use', + ]; + } + } } return $cleanupLog; diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index d718d3735..2e08ec6ad 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -11,11 +11,8 @@ class InstallDocker { use AsAction; - private string $dockerVersion; - public function handle(Server $server) { - $this->dockerVersion = config('constants.docker.minimum_required_version'); $supported_os_type = $server->validateOS(); if (! $supported_os_type) { throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); @@ -30,12 +27,14 @@ public function handle(Server $server) ); $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($serverCert->ssl_certificate); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); remote_process($commands, $server); @@ -116,7 +115,7 @@ public function handle(Server $server) private function getDebianDockerInstallCommand(): string { - return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. '. /etc/os-release && '. 'install -m 0755 -d /etc/apt/keyrings && '. 'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '. @@ -129,7 +128,7 @@ private function getDebianDockerInstallCommand(): string private function getRhelDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. 'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '. 'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '. 'systemctl start docker && '. @@ -139,7 +138,7 @@ private function getRhelDockerInstallCommand(): string private function getSuseDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (". + return 'curl -fsSL https://get.docker.com | sh || ('. 'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '. 'zypper refresh && '. 'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '. @@ -150,10 +149,6 @@ private function getSuseDockerInstallCommand(): string private function getArchDockerInstallCommand(): string { - // Use -Syu to perform full system upgrade before installing Docker - // Partial upgrades (-Sy without -u) are discouraged on Arch Linux - // as they can lead to broken dependencies and system instability - // Use --needed to skip reinstalling packages that are already up-to-date (idempotent) return 'pacman -Syu --noconfirm --needed docker docker-compose && '. 'systemctl enable docker.service && '. 'systemctl start docker.service'; @@ -161,6 +156,6 @@ private function getArchDockerInstallCommand(): string private function getGenericDockerInstallCommand(): string { - return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; + return 'curl -fsSL https://get.docker.com | sh'; } } diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index f72f23696..e4df5a061 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -177,6 +177,19 @@ public function handle(Server $server) $parsers_config = $config_path.'/parsers.conf'; $compose_path = $config_path.'/docker-compose.yml'; $readme_path = $config_path.'/README.md'; + if ($type === 'newrelic') { + $envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n"; + } elseif ($type === 'highlight') { + $envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n"; + } elseif ($type === 'axiom') { + $envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n"; + } elseif ($type === 'custom') { + $envContent = ''; + } else { + throw new \Exception('Unknown log drain type.'); + } + $envEncoded = base64_encode($envContent); + $command = [ "echo 'Saving configuration'", "mkdir -p $config_path", @@ -184,34 +197,10 @@ public function handle(Server $server) "echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null", "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null", "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null", - "test -f $config_path/.env && rm $config_path/.env", - ]; - if ($type === 'newrelic') { - $add_envs_command = [ - "echo LICENSE_KEY=$license_key >> $config_path/.env", - "echo BASE_URI=$base_uri >> $config_path/.env", - ]; - } elseif ($type === 'highlight') { - $add_envs_command = [ - "echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env", - ]; - } elseif ($type === 'axiom') { - $add_envs_command = [ - "echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env", - "echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env", - ]; - } elseif ($type === 'custom') { - $add_envs_command = [ - "touch $config_path/.env", - ]; - } else { - throw new \Exception('Unknown log drain type.'); - } - $restart_command = [ + "echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null", "echo 'Starting Fluent Bit'", "cd $config_path && docker compose up -d", ]; - $command = array_merge($command, $add_envs_command, $restart_command); return instant_remote_process($command, $server); } catch (\Throwable $e) { diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 1f248aec1..071f3ec46 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -4,6 +4,7 @@ use App\Events\SentinelRestarted; use App\Models\Server; +use App\Models\ServerSetting; use Lorisleiva\Actions\Concerns\AsAction; class StartSentinel @@ -23,6 +24,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); $token = data_get($server, 'settings.sentinel_token'); + if (! ServerSetting::isValidSentinelToken($token)) { + throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.'); + } $endpoint = data_get($server, 'settings.sentinel_custom_url'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $mountDir = '/data/coolify/sentinel'; @@ -49,7 +53,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer } $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)).'"'; + $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 0a20deae5..22c48aa89 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -30,7 +30,8 @@ public function handle(Server $server) ]); ['uptime' => $this->uptime, 'error' => $error] = $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.'
'; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; $server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 8790901cd..460600d69 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -33,7 +33,7 @@ public function handle(Service $service, bool $deleteVolumes, bool $deleteConnec } } foreach ($storagesToDelete as $storage) { - $commands[] = "docker volume rm -f $storage->name"; + $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name); } // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 6b5e1d4ac..17948d93b 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -40,10 +40,10 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s $commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true"; if (data_get($service, 'connect_to_docker_network')) { $compose = data_get($service, 'docker_compose', []); - $network = $service->destination->network; + $safeNetwork = escapeshellarg($service->destination->network); $serviceNames = data_get(Yaml::parse($compose), 'services', []); foreach ($serviceNames as $serviceName => $serviceConfig) { - $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} $network {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; + $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; } } diff --git a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php new file mode 100644 index 000000000..34c7d194a --- /dev/null +++ b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php @@ -0,0 +1,60 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Cancel the team's subscription at the end of the current billing period. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'Subscription is not active.']; + } + + if ($subscription->stripe_cancel_at_period_end) { + return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.']; + } + + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'cancel_at_period_end' => true, + ]); + + $subscription->update([ + 'stripe_cancel_at_period_end' => true, + ]); + + \Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } +} diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php new file mode 100644 index 000000000..b10d783db --- /dev/null +++ b/app/Actions/Stripe/RefundSubscription.php @@ -0,0 +1,156 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Check if the team's subscription is eligible for a refund. + * + * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null} + */ + public function checkEligibility(Team $team): array + { + $subscription = $team->subscription; + + if ($subscription?->stripe_refunded_at) { + return $this->ineligible('A refund has already been processed for this team.'); + } + + if (! $subscription?->stripe_subscription_id) { + return $this->ineligible('No active subscription found.'); + } + + if (! $subscription->stripe_invoice_paid) { + return $this->ineligible('Subscription invoice is not paid.'); + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + } catch (\Stripe\Exception\InvalidRequestException $e) { + return $this->ineligible('Subscription not found in Stripe.'); + } + + $currentPeriodEnd = $stripeSubscription->current_period_end; + + if (! in_array($stripeSubscription->status, ['active', 'trialing'])) { + return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd); + } + + $startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); + $daysSinceStart = (int) $startDate->diffInDays(now()); + $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; + + if ($daysRemaining <= 0) { + return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd); + } + + return [ + 'eligible' => true, + 'days_remaining' => $daysRemaining, + 'reason' => 'Eligible for refund.', + 'current_period_end' => $currentPeriodEnd, + ]; + } + + /** + * Process a full refund and cancel the subscription. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team): array + { + $eligibility = $this->checkEligibility($team); + + if (! $eligibility['eligible']) { + return ['success' => false, 'error' => $eligibility['reason']]; + } + + $subscription = $team->subscription; + + try { + $invoices = $this->stripe->invoices->all([ + 'subscription' => $subscription->stripe_subscription_id, + 'status' => 'paid', + 'limit' => 1, + ]); + + if (empty($invoices->data)) { + return ['success' => false, 'error' => 'No paid invoice found to refund.']; + } + + $invoice = $invoices->data[0]; + $paymentIntentId = $invoice->payment_intent; + + if (! $paymentIntentId) { + return ['success' => false, 'error' => 'No payment intent found on the invoice.']; + } + + $this->stripe->refunds->create([ + 'payment_intent' => $paymentIntentId, + ]); + + // Record refund immediately so it cannot be retried if cancel fails + $subscription->update([ + 'stripe_refunded_at' => now(), + 'stripe_feedback' => 'Refund requested by user', + 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(), + ]); + + try { + $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id); + } catch (\Exception $e) { + \Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage()); + send_internal_notification( + "CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required." + ); + } + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + ]); + + $team->subscriptionEnded(); + + \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Refund error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } + + /** + * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null} + */ + private function ineligible(string $reason, ?int $currentPeriodEnd = null): array + { + return [ + 'eligible' => false, + 'days_remaining' => 0, + 'reason' => $reason, + 'current_period_end' => $currentPeriodEnd, + ]; + } +} diff --git a/app/Actions/Stripe/ResumeSubscription.php b/app/Actions/Stripe/ResumeSubscription.php new file mode 100644 index 000000000..d8019def7 --- /dev/null +++ b/app/Actions/Stripe/ResumeSubscription.php @@ -0,0 +1,56 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Resume a subscription that was set to cancel at the end of the billing period. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_cancel_at_period_end) { + return ['success' => false, 'error' => 'Subscription is not set to cancel.']; + } + + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'cancel_at_period_end' => false, + ]); + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + ]); + + \Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } +} diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php new file mode 100644 index 000000000..d4d29af20 --- /dev/null +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -0,0 +1,207 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Fetch a full price preview for a quantity change from Stripe. + * Returns both the prorated amount due now and the recurring cost for the next billing cycle. + * + * @return array{success: bool, error: string|null, preview: array{due_now: int, recurring_subtotal: int, recurring_tax: int, recurring_total: int, unit_price: int, tax_description: string|null, quantity: int, currency: string}|null} + */ + public function fetchPricePreview(Team $team, int $quantity): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id || ! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'No active subscription found.', 'preview' => null]; + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + $item = $stripeSubscription->items->data[0] ?? null; + + if (! $item) { + return ['success' => false, 'error' => 'Could not retrieve subscription details.', 'preview' => null]; + } + + $currency = strtoupper($item->price->currency ?? 'usd'); + $billingInterval = $item->price->recurring->interval ?? 'month'; + + // Upcoming invoice gives us the prorated amount due now + $upcomingInvoice = $this->stripe->invoices->upcoming([ + 'customer' => $subscription->stripe_customer_id, + 'subscription' => $subscription->stripe_subscription_id, + 'subscription_items' => [ + ['id' => $item->id, 'quantity' => $quantity], + ], + 'subscription_proration_behavior' => 'create_prorations', + ]); + + // Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal + $taxPercentage = 0.0; + $taxDescription = null; + if (! empty($upcomingInvoice->total_tax_amounts)) { + $taxAmount = $upcomingInvoice->total_tax_amounts[0] ?? null; + if ($taxAmount?->tax_rate) { + $taxRate = $this->stripe->taxRates->retrieve($taxAmount->tax_rate); + $taxPercentage = (float) ($taxRate->percentage ?? 0); + $taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%'; + } + } + // Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy + if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) { + $taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2); + } + + // Recurring cost for next cycle — read from non-proration invoice lines + $recurringSubtotal = 0; + foreach ($upcomingInvoice->lines->data as $line) { + if (! $line->proration) { + $recurringSubtotal += $line->amount; + } + } + $unitPrice = $quantity > 0 ? (int) round($recurringSubtotal / $quantity) : 0; + + $recurringTax = $taxPercentage > 0 + ? (int) round($recurringSubtotal * $taxPercentage / 100) + : 0; + $recurringTotal = $recurringSubtotal + $recurringTax; + + // Due now = amount_due (accounts for customer balance/credits) minus recurring + $amountDue = $upcomingInvoice->amount_due ?? $upcomingInvoice->total ?? 0; + $dueNow = $amountDue - $recurringTotal; + + return [ + 'success' => true, + 'error' => null, + 'preview' => [ + 'due_now' => $dueNow, + 'recurring_subtotal' => $recurringSubtotal, + 'recurring_tax' => $recurringTax, + 'recurring_total' => $recurringTotal, + 'unit_price' => $unitPrice, + 'tax_description' => $taxDescription, + 'quantity' => $quantity, + 'currency' => $currency, + 'billing_interval' => $billingInterval, + ], + ]; + } catch (\Exception $e) { + \Log::warning("Stripe fetch price preview error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Could not load price preview.', 'preview' => null]; + } + } + + /** + * Update the subscription quantity (server limit) for a team. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team, int $quantity): array + { + if ($quantity < self::MIN_SERVER_LIMIT) { + return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.']; + } + + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'Subscription is not active.']; + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + $item = $stripeSubscription->items->data[0] ?? null; + + if (! $item?->id) { + return ['success' => false, 'error' => 'Could not find subscription item.']; + } + + $previousQuantity = $item->quantity ?? $team->custom_server_limit; + + $updatedSubscription = $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $quantity], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]); + + // Check if the proration invoice was paid + $latestInvoice = $updatedSubscription->latest_invoice; + if ($latestInvoice && $latestInvoice->status !== 'paid') { + \Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}."); + + // Revert subscription quantity on Stripe + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $previousQuantity], + ], + 'proration_behavior' => 'none', + ]); + } catch (\Exception $revertException) { + \Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage()); + send_internal_notification( + "CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required." + ); + } + + // Void the unpaid invoice + if ($latestInvoice->id) { + $this->stripe->invoices->voidInvoice($latestInvoice->id); + } + + return ['success' => false, 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.']; + } + + $team->update([ + 'custom_server_limit' => $quantity, + ]); + + ServerLimitCheckJob::dispatch($team); + + \Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (InvalidRequestException $e) { + \Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Update subscription quantity error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } + + private function formatAmount(int $cents, string $currency): string + { + return strtoupper($currency) === 'USD' + ? '$'.number_format($cents / 100, 2) + : number_format($cents / 100, 2).' '.$currency; + } +} diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index def01b265..09563a2c3 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -14,7 +14,7 @@ class CleanupUnreachableServers extends Command public function handle() { echo "Running unreachable server cleanup...\n"; - $servers = Server::where('unreachable_count', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get(); + $servers = Server::where('unreachable_count', '>=', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get(); if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index e64f86926..46f6b4edd 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -36,7 +36,14 @@ public function handle(): int $this->newLine(); $job = new SyncStripeSubscriptionsJob($fix); - $result = $job->handle(); + $fetched = 0; + $result = $job->handle(function (int $count) use (&$fetched): void { + $fetched = $count; + $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}"); + }); + if ($fetched > 0) { + $this->output->write("\r".str_repeat(' ', 60)."\r"); + } if (isset($result['error'])) { $this->error($result['error']); @@ -68,6 +75,19 @@ public function handle(): int $this->info('No discrepancies found. All subscriptions are in sync.'); } + if (count($result['resubscribed']) > 0) { + $this->newLine(); + $this->warn('Resubscribed users (same email, different customer): '.count($result['resubscribed'])); + $this->newLine(); + + foreach ($result['resubscribed'] as $resub) { + $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}"); + $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})"); + $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]"); + $this->newLine(); + } + } + if (count($result['errors']) > 0) { $this->newLine(); $this->error('Errors encountered: '.count($result['errors'])); diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index acc6dc2f9..7daa6ba28 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -30,32 +30,32 @@ public function init() // Generate APP_KEY if not exists if (empty(config('app.key'))) { - echo "Generating APP_KEY.\n"; + echo " INFO Generating APP_KEY.\n"; Artisan::call('key:generate'); } // Generate STORAGE link if not exists if (! file_exists(public_path('storage'))) { - echo "Generating STORAGE link.\n"; + echo " INFO Generating storage link.\n"; Artisan::call('storage:link'); } // Seed database if it's empty $settings = InstanceSettings::find(0); if (! $settings) { - echo "Initializing instance, seeding database.\n"; + echo " INFO Initializing instance, seeding database.\n"; Artisan::call('migrate --seed'); } else { - echo "Instance already initialized.\n"; + echo " INFO Instance already initialized.\n"; } // Clean up stuck jobs and stale locks on development startup try { - echo "Cleaning up Redis (stuck jobs and stale locks)...\n"; + echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n"; Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]); - echo "Redis cleanup completed.\n"; + echo " INFO Redis cleanup completed.\n"; } catch (\Throwable $e) { - echo "Error in cleanup:redis: {$e->getMessage()}\n"; + echo " ERROR Redis cleanup failed: {$e->getMessage()}\n"; } try { @@ -66,10 +66,10 @@ public function init() ]); if ($updatedTaskCount > 0) { - echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n"; + echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n"; } } catch (\Throwable $e) { - echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n"; + echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n"; } try { @@ -80,10 +80,10 @@ public function init() ]); if ($updatedBackupCount > 0) { - echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n"; + echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n"; } } catch (\Throwable $e) { - echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n"; + echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n"; } CheckHelperImageJob::dispatch(); diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php index 2b266c258..224c10792 100644 --- a/app/Console/Commands/Generate/OpenApi.php +++ b/app/Console/Commands/Generate/OpenApi.php @@ -32,7 +32,8 @@ public function handle() echo $process->output(); $yaml = file_get_contents('openapi.yaml'); - $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT); + + $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n"; file_put_contents('openapi.json', $json); echo "Converted OpenAPI YAML to JSON.\n"; } diff --git a/app/Console/Commands/GenerateTestingSchema.php b/app/Console/Commands/GenerateTestingSchema.php new file mode 100644 index 000000000..00fd90c25 --- /dev/null +++ b/app/Console/Commands/GenerateTestingSchema.php @@ -0,0 +1,222 @@ + 'INTEGER', + '/\binteger\b/' => 'INTEGER', + '/\bsmallint\b/' => 'INTEGER', + '/\bboolean\b/' => 'INTEGER', + '/character varying\(\d+\)/' => 'TEXT', + '/timestamp\(\d+\) without time zone/' => 'TEXT', + '/timestamp\(\d+\) with time zone/' => 'TEXT', + '/\bjsonb\b/' => 'TEXT', + '/\bjson\b/' => 'TEXT', + '/\buuid\b/' => 'TEXT', + '/double precision/' => 'REAL', + '/numeric\(\d+,\d+\)/' => 'REAL', + '/\bdate\b/' => 'TEXT', + ]; + + private array $castRemovals = [ + '::character varying', + '::text', + '::integer', + '::boolean', + '::timestamp without time zone', + '::timestamp with time zone', + '::numeric', + ]; + + public function handle(): int + { + $connection = $this->option('connection'); + + if (DB::connection($connection)->getDriverName() !== 'pgsql') { + $this->error("Connection '{$connection}' is not PostgreSQL."); + + return self::FAILURE; + } + + $this->info('Reading schema from PostgreSQL...'); + + $tables = $this->getTables($connection); + $lastMigration = DB::connection($connection) + ->table('migrations') + ->orderByDesc('id') + ->value('migration'); + + $output = []; + $output[] = '-- Generated by: php artisan schema:generate-testing'; + $output[] = '-- Date: '.now()->format('Y-m-d H:i:s'); + $output[] = '-- Last migration: '.($lastMigration ?? 'none'); + $output[] = ''; + + foreach ($tables as $table) { + $columns = $this->getColumns($connection, $table); + $output[] = $this->generateCreateTable($table, $columns); + } + + $indexes = $this->getIndexes($connection, $tables); + foreach ($indexes as $index) { + $output[] = $index; + } + + $output[] = ''; + $output[] = '-- Migration records'; + + $migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get(); + foreach ($migrations as $m) { + $migration = str_replace("'", "''", $m->migration); + $output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});"; + } + + $path = database_path('schema/testing-schema.sql'); + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + + file_put_contents($path, implode("\n", $output)."\n"); + + $this->info("Schema written to {$path}"); + $this->info(count($tables).' tables, '.count($migrations).' migration records.'); + + return self::SUCCESS; + } + + private function getTables(string $connection): array + { + return collect(DB::connection($connection)->select( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename" + ))->pluck('tablename')->toArray(); + } + + private function getColumns(string $connection, string $table): array + { + return DB::connection($connection)->select( + "SELECT column_name, data_type, character_maximum_length, column_default, + is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? + ORDER BY ordinal_position", + [$table] + ); + } + + private function generateCreateTable(string $table, array $columns): string + { + $lines = []; + + foreach ($columns as $col) { + $lines[] = ' '.$this->generateColumnDef($table, $col); + } + + return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n"; + } + + private function generateColumnDef(string $table, object $col): string + { + $name = $col->column_name; + $sqliteType = $this->convertType($col); + + // Auto-increment primary key for id columns + if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) { + return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL"; + } + + $parts = ["\"{$name}\"", $sqliteType]; + + // Default value + $default = $col->column_default; + if ($default !== null && ! str_contains($default, 'nextval')) { + $default = $this->cleanDefault($default); + $parts[] = "DEFAULT {$default}"; + } + + // NOT NULL + if ($col->is_nullable === 'NO') { + $parts[] = 'NOT NULL'; + } + + return implode(' ', $parts); + } + + private function convertType(object $col): string + { + $pgType = $col->data_type; + + return match (true) { + in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER', + $pgType === 'boolean' => 'INTEGER', + in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT', + str_contains($pgType, 'timestamp') => 'TEXT', + in_array($pgType, ['json', 'jsonb']) => 'TEXT', + $pgType === 'uuid' => 'TEXT', + $pgType === 'double precision' => 'REAL', + $pgType === 'numeric' => 'REAL', + $pgType === 'date' => 'TEXT', + default => 'TEXT', + }; + } + + private function cleanDefault(string $default): string + { + foreach ($this->castRemovals as $cast) { + $default = str_replace($cast, '', $default); + } + + // Remove array type casts like ::text[] + $default = preg_replace('/::[\w\s]+(\[\])?/', '', $default); + + return $default; + } + + private function getIndexes(string $connection, array $tables): array + { + $results = []; + + $indexes = DB::connection($connection)->select( + "SELECT indexname, tablename, indexdef FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY tablename, indexname" + ); + + foreach ($indexes as $idx) { + $def = $idx->indexdef; + + // Skip primary key indexes + if (str_contains($def, '_pkey')) { + continue; + } + + // Skip PG-specific indexes (GIN, GIST, expression indexes) + if (preg_match('/USING (gin|gist)/i', $def)) { + continue; + } + if (str_contains($def, '->>') || str_contains($def, '::')) { + continue; + } + + // Convert to SQLite-compatible CREATE INDEX + $unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : ''; + + // Extract columns from the index definition + if (preg_match('/\((.+)\)$/', $def, $m)) { + $cols = $m[1]; + $results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});"; + } + } + + return $results; + } +} diff --git a/app/Console/Commands/Horizon.php b/app/Console/Commands/Horizon.php deleted file mode 100644 index d3e35ca5a..000000000 --- a/app/Console/Commands/Horizon.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Horizon is enabled on this server.'); - $this->call('horizon'); - exit(0); - } else { - exit(0); - } - } -} diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 66cb77838..e95c29f72 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -212,18 +212,19 @@ private function cleanupUnusedNetworkFromCoolifyProxy() $removeNetworks = $allNetworks->diff($networks); $commands = collect(); foreach ($removeNetworks as $network) { - $out = instant_remote_process(["docker network inspect -f json $network | jq '.[].Containers | if . == {} then null else . end'"], $server, false); + $safe = escapeshellarg($network); + $out = instant_remote_process(["docker network inspect -f json {$safe} | jq '.[].Containers | if . == {} then null else . end'"], $server, false); if (empty($out)) { - $commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true"); - $commands->push("docker network rm $network >/dev/null 2>&1 || true"); + $commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true"); + $commands->push("docker network rm {$safe} >/dev/null 2>&1 || true"); } else { $data = collect(json_decode($out, true)); if ($data->count() === 1) { // If only coolify-proxy itself is connected to that network (it should not be possible, but who knows) $isCoolifyProxyItself = data_get($data->first(), 'Name') === 'coolify-proxy'; if ($isCoolifyProxyItself) { - $commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true"); - $commands->push("docker network rm $network >/dev/null 2>&1 || true"); + $commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true"); + $commands->push("docker network rm {$safe} >/dev/null 2>&1 || true"); } } } diff --git a/app/Console/Commands/ScheduledJobDiagnostics.php b/app/Console/Commands/ScheduledJobDiagnostics.php new file mode 100644 index 000000000..77881284c --- /dev/null +++ b/app/Console/Commands/ScheduledJobDiagnostics.php @@ -0,0 +1,255 @@ +option('type'); + $serverFilter = $this->option('server'); + + $this->outputHeartbeat(); + + if (in_array($type, ['all', 'docker-cleanup'])) { + $this->inspectDockerCleanups($serverFilter); + } + + if (in_array($type, ['all', 'backups'])) { + $this->inspectBackups(); + } + + if (in_array($type, ['all', 'tasks'])) { + $this->inspectTasks(); + } + + if (in_array($type, ['all', 'server-jobs'])) { + $this->inspectServerJobs($serverFilter); + } + + return self::SUCCESS; + } + + private function outputHeartbeat(): void + { + $heartbeat = Cache::get('scheduled-job-manager:heartbeat'); + if ($heartbeat) { + $age = Carbon::parse($heartbeat)->diffForHumans(); + $this->info("Scheduler heartbeat: {$heartbeat} ({$age})"); + } else { + $this->error('Scheduler heartbeat: MISSING — ScheduledJobManager may not be running'); + } + $this->newLine(); + } + + private function inspectDockerCleanups(?string $serverFilter): void + { + $this->info('=== Docker Cleanup Jobs ==='); + + $servers = $this->getServers($serverFilter); + + $rows = []; + foreach ($servers as $server) { + $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $dedupKey = "docker-cleanup:{$server->id}"; + $cacheValue = Cache::get($dedupKey); + $timezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + + if (validate_timezone($timezone) === false) { + $timezone = config('app.timezone'); + } + + $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey); + + $lastExecution = DockerCleanupExecution::where('server_id', $server->id) + ->latest() + ->first(); + + $rows[] = [ + $server->id, + $server->name, + $timezone, + $frequency, + $dedupKey, + $cacheValue ?? '', + $wouldFire ? 'YES' : 'no', + $lastExecution ? $lastExecution->status.' @ '.$lastExecution->created_at : 'never', + ]; + } + + $this->table( + ['ID', 'Server', 'TZ', 'Frequency', 'Dedup Key', 'Cache Value', 'Would Fire', 'Last Execution'], + $rows + ); + $this->newLine(); + } + + private function inspectBackups(): void + { + $this->info('=== Scheduled Backups ==='); + + $backups = ScheduledDatabaseBackup::with(['database']) + ->where('enabled', true) + ->get(); + + $rows = []; + foreach ($backups as $backup) { + $server = $backup->server(); + $frequency = $backup->frequency; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $dedupKey = "scheduled-backup:{$backup->id}"; + $cacheValue = Cache::get($dedupKey); + $timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone'); + + if (validate_timezone($timezone) === false) { + $timezone = config('app.timezone'); + } + + $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey); + + $rows[] = [ + $backup->id, + $backup->database_type ?? 'unknown', + $server?->name ?? 'N/A', + $frequency, + $cacheValue ?? '', + $wouldFire ? 'YES' : 'no', + ]; + } + + $this->table( + ['Backup ID', 'DB Type', 'Server', 'Frequency', 'Cache Value', 'Would Fire'], + $rows + ); + $this->newLine(); + } + + private function inspectTasks(): void + { + $this->info('=== Scheduled Tasks ==='); + + $tasks = ScheduledTask::with(['service', 'application']) + ->where('enabled', true) + ->get(); + + $rows = []; + foreach ($tasks as $task) { + $server = $task->server(); + $frequency = $task->frequency; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $dedupKey = "scheduled-task:{$task->id}"; + $cacheValue = Cache::get($dedupKey); + $timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone'); + + if (validate_timezone($timezone) === false) { + $timezone = config('app.timezone'); + } + + $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey); + + $rows[] = [ + $task->id, + $task->name, + $server?->name ?? 'N/A', + $frequency, + $cacheValue ?? '', + $wouldFire ? 'YES' : 'no', + ]; + } + + $this->table( + ['Task ID', 'Name', 'Server', 'Frequency', 'Cache Value', 'Would Fire'], + $rows + ); + $this->newLine(); + } + + private function inspectServerJobs(?string $serverFilter): void + { + $this->info('=== Server Manager Jobs ==='); + + $servers = $this->getServers($serverFilter); + + $rows = []; + foreach ($servers as $server) { + $timezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + if (validate_timezone($timezone) === false) { + $timezone = config('app.timezone'); + } + + $dedupKeys = [ + "sentinel-restart:{$server->id}" => '0 0 * * *', + "server-patch-check:{$server->id}" => '0 0 * * 0', + "server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *', + "server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'), + ]; + + foreach ($dedupKeys as $dedupKey => $frequency) { + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $cacheValue = Cache::get($dedupKey); + $wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey); + + $rows[] = [ + $server->id, + $server->name, + $dedupKey, + $frequency, + $cacheValue ?? '', + $wouldFire ? 'YES' : 'no', + ]; + } + } + + $this->table( + ['Server ID', 'Server', 'Dedup Key', 'Frequency', 'Cache Value', 'Would Fire'], + $rows + ); + $this->newLine(); + } + + private function getServers(?string $serverFilter): \Illuminate\Support\Collection + { + $query = Server::with('settings')->where('ip', '!=', '1.2.3.4'); + + if ($serverFilter) { + $query->where('id', $serverFilter); + } + + if (isCloud()) { + $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); + $own = Team::find(0)?->servers()->with('settings')->get() ?? collect(); + + return $servers->merge($own); + } + + return $query->get(); + } +} diff --git a/app/Console/Commands/Scheduler.php b/app/Console/Commands/Scheduler.php deleted file mode 100644 index ee64368c3..000000000 --- a/app/Console/Commands/Scheduler.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Scheduler is enabled on this server.'); - $this->call('schedule:work'); - exit(0); - } else { - exit(0); - } - } -} diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 0a98f1dc8..9ac3371e0 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -363,6 +363,162 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b } } + /** + * Sync install.sh, docker-compose, and env files to GitHub repository via PR + */ + private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool + { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("Syncing $envLabel files to GitHub repository..."); + try { + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp; + $branchName = 'update-files-'.$timestamp; + + // Clone the repository + $this->info('Cloning coolify-cdn repository...'); + $output = []; + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Copy each file to its target path in the CDN repo + $copiedFiles = []; + foreach ($files as $sourceFile => $targetPath) { + if (! file_exists($sourceFile)) { + $this->warn("Source file not found, skipping: $sourceFile"); + + continue; + } + + $destPath = "$tmpDir/$targetPath"; + $destDir = dirname($destPath); + + if (! is_dir($destDir)) { + if (! mkdir($destDir, 0755, true)) { + $this->error("Failed to create directory: $destDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + if (copy($sourceFile, $destPath) === false) { + $this->error("Failed to copy $sourceFile to $destPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + $copiedFiles[] = $targetPath; + $this->info("Copied: $targetPath"); + } + + if (empty($copiedFiles)) { + $this->warn('No files were copied. Nothing to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // Stage all copied files + $this->info('Staging changes...'); + $output = []; + $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; + exec($stageCmd, $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Check for changes + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + if (empty(array_filter($statusOutput))) { + $this->info('All files are already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // Commit changes + $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); + $fileList = implode("\n- ", $copiedFiles); + $prBody = "Automated update of $envLabel files:\n- $fileList"; + $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // Clean up + exec('rm -rf '.escapeshellarg($tmpDir)); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info('Files synced: '.count($copiedFiles)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing files to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Sync versions.json to GitHub repository via PR */ @@ -581,11 +737,130 @@ public function handle() $versions_location = "$parent_dir/other/nightly/$versions"; } if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->newLine(); + + // Build file mapping for diff if ($nightly) { - $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); + $fileMapping = [ + $compose_file_location => 'docker/nightly/docker-compose.yml', + $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', + $production_env_location => 'environment/nightly/.env.production', + $upgrade_script_location => 'scripts/nightly/upgrade.sh', + $install_script_location => 'scripts/nightly/install.sh', + ]; } else { - $this->info('About to sync files PRODUCTION (docker-compose.yml, docker-compose.prod.yml, upgrade.sh, install.sh, etc) to BunnyCDN.'); + $fileMapping = [ + $compose_file_location => 'docker/docker-compose.yml', + $compose_file_prod_location => 'docker/docker-compose.prod.yml', + $production_env_location => 'environment/.env.production', + $upgrade_script_location => 'scripts/upgrade.sh', + $install_script_location => 'scripts/install.sh', + ]; } + + // BunnyCDN file mapping (local file => CDN URL path) + $bunnyFileMapping = [ + $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", + $compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod", + $production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env", + $upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script", + $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", + ]; + + $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); + @mkdir($diffTmpDir, 0755, true); + $hasChanges = false; + + // Diff against BunnyCDN + $this->info('Fetching files from BunnyCDN to compare...'); + foreach ($bunnyFileMapping as $localFile => $cdnUrl) { + if (! file_exists($localFile)) { + $this->warn('Local file not found: '.$localFile); + + continue; + } + + $fileName = basename($cdnUrl); + $remoteTmp = "$diffTmpDir/bunny-$fileName"; + + try { + $response = Http::timeout(10)->get($cdnUrl); + if ($response->successful()) { + file_put_contents($remoteTmp, $response->body()); + $diffOutput = []; + exec('diff -u '.escapeshellarg($remoteTmp).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); + if ($diffCode !== 0) { + $hasChanges = true; + $this->newLine(); + $this->info("--- BunnyCDN: $bunny_cdn_path/$fileName"); + $this->info("+++ Local: $fileName"); + foreach ($diffOutput as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + $this->line($line); + } + } + } else { + $this->info("NEW on BunnyCDN: $bunny_cdn_path/$fileName (HTTP {$response->status()})"); + $hasChanges = true; + } + } catch (\Throwable $e) { + $this->warn("Could not fetch $cdnUrl: {$e->getMessage()}"); + } + } + + // Diff against GitHub coolify-cdn repo + $this->newLine(); + $this->info('Fetching coolify-cdn repo to compare...'); + $output = []; + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode); + + if ($returnCode === 0) { + foreach ($fileMapping as $localFile => $cdnPath) { + $remotePath = "$diffTmpDir/repo/$cdnPath"; + if (! file_exists($localFile)) { + continue; + } + if (! file_exists($remotePath)) { + $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)"); + $hasChanges = true; + + continue; + } + + $diffOutput = []; + exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); + if ($diffCode !== 0) { + $hasChanges = true; + $this->newLine(); + $this->info("--- GitHub: $cdnPath"); + $this->info("+++ Local: $cdnPath"); + foreach ($diffOutput as $line) { + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + $this->line($line); + } + } + } + } else { + $this->warn('Could not fetch coolify-cdn repo for diff.'); + } + + exec('rm -rf '.escapeshellarg($diffTmpDir)); + + if (! $hasChanges) { + $this->newLine(); + $this->info('No differences found. All files are already up to date.'); + + return; + } + + $this->newLine(); + $confirmed = confirm('Are you sure you want to sync?'); if (! $confirmed) { return; @@ -692,7 +967,34 @@ public function handle() $pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); - $this->info('All files uploaded & purged...'); + $this->info('All files uploaded & purged to BunnyCDN.'); + $this->newLine(); + + // Sync files to GitHub CDN repository via PR + $this->info('Creating GitHub PR for coolify-cdn repository...'); + if ($nightly) { + $files = [ + $compose_file_location => 'docker/nightly/docker-compose.yml', + $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', + $production_env_location => 'environment/nightly/.env.production', + $upgrade_script_location => 'scripts/nightly/upgrade.sh', + $install_script_location => 'scripts/nightly/install.sh', + ]; + } else { + $files = [ + $compose_file_location => 'docker/docker-compose.yml', + $compose_file_prod_location => 'docker/docker-compose.prod.yml', + $production_env_location => 'environment/.env.production', + $upgrade_script_location => 'scripts/upgrade.sh', + $install_script_location => 'scripts/install.sh', + ]; + } + + $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly); + $this->newLine(); + $this->info('=== Summary ==='); + $this->info('BunnyCDN sync: Complete'); + $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed')); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index d82d3a1b9..c5e12b7ee 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -40,7 +40,7 @@ protected function schedule(Schedule $schedule): void } // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); - $this->scheduleInstance->command('cleanup:redis')->weekly(); + $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); if (isDev()) { // Instance Jobs diff --git a/app/Data/CoolifyTaskArgs.php b/app/Data/CoolifyTaskArgs.php deleted file mode 100644 index 24132157a..000000000 --- a/app/Data/CoolifyTaskArgs.php +++ /dev/null @@ -1,30 +0,0 @@ -status = ProcessStatus::QUEUED->value; - } - } -} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 723c6d4a5..aa9d06996 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; class SshMultiplexingHelper { @@ -37,7 +38,7 @@ public static function ensureMultiplexedConnection(Server $server): bool if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $checkCommand .= "{$server->user}@{$server->ip}"; + $checkCommand .= self::escapedUserAtHost($server); $process = Process::run($checkCommand); if ($process->exitCode() !== 0) { @@ -80,7 +81,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; } $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); - $establishCommand .= "{$server->user}@{$server->ip}"; + $establishCommand .= self::escapedUserAtHost($server); $establishProcess = Process::run($establishCommand); if ($establishProcess->exitCode() !== 0) { return false; @@ -101,7 +102,7 @@ public static function removeMuxFile(Server $server) if (data_get($server, 'settings.is_cloudflare_tunnel')) { $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $closeCommand .= "{$server->user}@{$server->ip}"; + $closeCommand .= self::escapedUserAtHost($server); Process::run($closeCommand); // Clear connection metadata from cache @@ -141,9 +142,9 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { - $scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}"; + $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { - $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; } return $scp_command; @@ -189,13 +190,18 @@ public static function generateSshCommand(Server $server, string $command, bool $delimiter = base64_encode($delimiter); $command = str_replace($delimiter, '', $command); - $ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL + $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; return $ssh_command; } + private static function escapedUserAtHost(Server $server): string + { + return escapeshellarg($server->user).'@'.escapeshellarg($server->ip); + } + private static function isMultiplexingEnabled(): bool { return config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'); @@ -204,12 +210,37 @@ private static function isMultiplexingEnabled(): bool private static function validateSshKey(PrivateKey $privateKey): void { $keyLocation = $privateKey->getKeyLocation(); - $checkKeyCommand = "ls $keyLocation 2>/dev/null"; - $keyCheckProcess = Process::run($checkKeyCommand); + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); - if ($keyCheckProcess->exitCode() !== 0) { + $needsRewrite = false; + + if (! $disk->exists($filename)) { + $needsRewrite = true; + } else { + $diskContent = $disk->get($filename); + if ($diskContent !== $privateKey->private_key) { + Log::warning('SSH key file content does not match database, resyncing', [ + 'key_uuid' => $privateKey->uuid, + ]); + $needsRewrite = true; + } + } + + if ($needsRewrite) { $privateKey->storeInFileSystem(); } + + // Ensure correct permissions (SSH requires 0600) + if (file_exists($keyLocation)) { + $currentPerms = fileperms($keyLocation) & 0777; + if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { + Log::warning('Failed to set SSH key file permissions to 0600', [ + 'key_uuid' => $privateKey->uuid, + 'path' => $keyLocation, + ]); + } + } } private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string @@ -224,9 +255,9 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati // Bruh if ($isScp) { - $options .= "-P {$server->port} "; + $options .= '-P '.escapeshellarg((string) $server->port).' '; } else { - $options .= "-p {$server->port} "; + $options .= '-p '.escapeshellarg((string) $server->port).' '; } return $options; @@ -245,7 +276,7 @@ public static function isConnectionHealthy(Server $server): bool 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\"'"; + $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'"; $process = Process::run($healthCommand); $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1e045ff5a..3d92300f1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -11,6 +11,8 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -18,9 +20,11 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use App\Support\ValidationPatterns; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -213,7 +217,7 @@ public function applications(Request $request) type: 'object', properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], - 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], + 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], ], ), ], @@ -226,6 +230,7 @@ public function applications(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) ), @@ -378,7 +383,7 @@ public function create_public_application(Request $request) type: 'object', properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], - 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], + 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], ], ), ], @@ -391,6 +396,7 @@ public function create_public_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) ), @@ -543,7 +549,7 @@ public function create_private_gh_app_application(Request $request) type: 'object', properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], - 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], + 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], ], ), ], @@ -556,6 +562,7 @@ public function create_private_gh_app_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) ), @@ -999,10 +1006,10 @@ private function create_application(Request $request, $type) $this->authorize('create', Application::class); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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', 'custom_network_aliases', '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', 'dockerfile_location', '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', 'autogenerate_domain', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', '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', 'dockerfile_location', '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', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1051,6 +1058,7 @@ private function create_application(Request $request, $type) $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false); if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -1095,13 +1103,23 @@ private function create_application(Request $request, $type) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } if ($type === 'public') { $validationRules = [ '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', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1137,14 +1155,14 @@ private function create_application(Request $request, $type) $request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch)); } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { $dockerComposeDomains = collect($request->docker_compose_domains); @@ -1253,6 +1271,10 @@ private function create_application(Request $request, $type) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } + if (isset($isPreserveRepositoryEnabled)) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1297,7 +1319,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1333,7 +1354,7 @@ private function create_application(Request $request, $type) } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); @@ -1372,7 +1393,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { @@ -1486,6 +1507,10 @@ private function create_application(Request $request, $type) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } + if (isset($isPreserveRepositoryEnabled)) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } if ($application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -1525,7 +1550,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1562,7 +1586,7 @@ private function create_application(Request $request, $type) } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $privateKey = PrivateKey::whereTeamId($teamId)->where('uuid', $request->private_key_uuid)->first(); @@ -1573,7 +1597,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { @@ -1683,6 +1707,10 @@ private function create_application(Request $request, $type) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } + if (isset($isPreserveRepositoryEnabled)) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } if ($application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -1731,7 +1759,7 @@ private function create_application(Request $request, $type) } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } if (! isBase64Encoded($request->dockerfile)) { @@ -1760,7 +1788,7 @@ private function create_application(Request $request, $type) } $application = new Application; - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $application->fqdn = $fqdn; $application->ports_exposes = $port; $application->build_pack = 'dockerfile'; @@ -1839,7 +1867,7 @@ private function create_application(Request $request, $type) $request->offsetSet('name', 'docker-image-'.new Cuid2); } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } // Process docker image name and tag using DockerImageParser @@ -1872,7 +1900,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $application->fqdn = $fqdn; $application->build_pack = 'dockerimage'; $application->destination_id = $destination->id; @@ -1963,7 +1991,7 @@ private function create_application(Request $request, $type) ], 422); } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } if (! isBase64Encoded($request->docker_compose_raw)) { @@ -1988,7 +2016,7 @@ private function create_application(Request $request, $type) $service = new Service; removeUnnecessaryFieldsFromRequest($request); - $service->fill($request->all()); + $service->fill($request->only($allowedFields)); $service->docker_compose_raw = $dockerComposeRaw; $service->environment_id = $environment->id; @@ -2369,7 +2397,7 @@ public function delete_by_uuid(Request $request) type: 'object', properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], - 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], + 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], ], ), ], @@ -2378,6 +2406,7 @@ public function delete_by_uuid(Request $request) '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.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'], ], ) ), @@ -2449,7 +2478,7 @@ public function update_by_uuid(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -2463,20 +2492,17 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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', 'custom_network_aliases', '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', 'dockerfile_location', 'docker_compose_location', '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', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', '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', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', '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', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; $validationRules = [ 'name' => 'string|max:255', 'description' => 'string|nullable', 'static_image' => 'string', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.domain' => 'string|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', 'custom_nginx_configuration' => 'string|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', @@ -2522,7 +2548,7 @@ public function update_by_uuid(Request $request) } } $return = $this->validateDataApplications($request, $server); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2713,7 +2739,7 @@ public function update_by_uuid(Request $request) $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); - + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled'); if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -2748,10 +2774,13 @@ public function update_by_uuid(Request $request) $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); } - + if ($request->has('is_preserve_repository_enabled')) { + $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; + $application->settings->save(); + } removeUnnecessaryFieldsFromRequest($request); - $data = $request->all(); + $data = $request->only($allowedFields); if ($requestHasDomains && $server->isProxyShouldRun()) { data_set($data, 'fqdn', $domains); } @@ -2919,10 +2948,7 @@ public function envs(Request $request) new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'], - ] + ref: '#/components/schemas/EnvironmentVariable' ) ), ] @@ -2943,7 +2969,7 @@ public function envs(Request $request) )] public function update_env_by_uuid(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2951,10 +2977,10 @@ public function update_env_by_uuid(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -2973,6 +2999,7 @@ public function update_env_by_uuid(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -3014,6 +3041,9 @@ public function update_env_by_uuid(Request $request) if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { $env->is_buildtime = $request->is_buildtime; } + if ($request->has('comment') && $env->comment != $request->comment) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -3044,6 +3074,9 @@ public function update_env_by_uuid(Request $request) if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { $env->is_buildtime = $request->is_buildtime; } + if ($request->has('comment') && $env->comment != $request->comment) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -3145,10 +3178,10 @@ public function create_bulk_envs(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3165,7 +3198,7 @@ public function create_bulk_envs(Request $request) ], 400); } $bulk_data = collect($bulk_data)->map(function ($item) { - return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']); + return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']); }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { @@ -3178,6 +3211,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { return response()->json([ @@ -3210,6 +3244,9 @@ public function create_bulk_envs(Request $request) if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { $env->is_buildtime = $item->get('is_buildtime'); } + if ($item->has('comment') && $env->comment != $item->get('comment')) { + $env->comment = $item->get('comment'); + } $env->save(); } else { $env = $application->environment_variables()->create([ @@ -3221,6 +3258,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => $is_shown_once, 'is_runtime' => $item->get('is_runtime', true), 'is_buildtime' => $item->get('is_buildtime', true), + 'comment' => $item->get('comment'), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3244,6 +3282,9 @@ public function create_bulk_envs(Request $request) if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { $env->is_buildtime = $item->get('is_buildtime'); } + if ($item->has('comment') && $env->comment != $item->get('comment')) { + $env->comment = $item->get('comment'); + } $env->save(); } else { $env = $application->environment_variables()->create([ @@ -3255,6 +3296,7 @@ public function create_bulk_envs(Request $request) 'is_shown_once' => $is_shown_once, 'is_runtime' => $item->get('is_runtime', true), 'is_buildtime' => $item->get('is_buildtime', true), + 'comment' => $item->get('comment'), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3336,13 +3378,13 @@ public function create_bulk_envs(Request $request) )] public function create_env(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3361,6 +3403,7 @@ public function create_env(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -3396,6 +3439,7 @@ public function create_env(Request $request) 'is_shown_once' => $request->is_shown_once ?? false, 'is_runtime' => $request->is_runtime ?? true, 'is_buildtime' => $request->is_buildtime ?? true, + 'comment' => $request->comment ?? null, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3420,6 +3464,7 @@ public function create_env(Request $request) 'is_shown_once' => $request->is_shown_once ?? false, 'is_runtime' => $request->is_runtime ?? true, 'is_buildtime' => $request->is_buildtime ?? true, + 'comment' => $request->comment ?? null, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3496,7 +3541,7 @@ public function delete_env_by_uuid(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -3506,7 +3551,7 @@ public function delete_env_by_uuid(Request $request) $this->authorize('manageEnvironment', $application); - $found_env = EnvironmentVariable::where('uuid', $request->env_uuid) + $found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) ->where('resourceable_type', Application::class) ->where('resourceable_id', $application->id) ->first(); @@ -3656,6 +3701,15 @@ public function action_deploy(Request $request) type: 'string', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -3704,7 +3758,8 @@ public function action_stop(Request $request) $this->authorize('deploy', $application); - StopApplication::dispatch($application); + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopApplication::dispatch($application, false, $dockerCleanup); return response()->json( [ @@ -3895,4 +3950,528 @@ private function validateDataApplications(Request $request, Server $server) } } } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'list-storages-by-application-uuid', + 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', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by application UUID.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')), + ], + ), + ), + 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 storages(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('view', $application); + + $persistentStorages = $application->persistentStorages->sortBy('id')->values(); + $fileStorages = $application->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'update-storage-by-application-uuid', + 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', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type'], + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'], + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'], + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], + 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 200, + description: 'Storage updated.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('update', $application); + + $validator = customApiValidator($request->all(), [ + 'uuid' => 'string', + 'id' => 'integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + ]); + + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + 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); + } + + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + + if ($request->type === 'persistent') { + $storage = $application->persistentStorages->where($lookupField, $lookupValue)->first(); + } else { + $storage = $application->fileStorages->where($lookupField, $lookupValue)->first(); + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + + // Always allowed + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + } else { + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + + $storage->save(); + + return response()->json($storage); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for an application.', + path: '/applications/{uuid}/storages', + operationId: 'create-storage-by-application-uuid', + 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') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path'], + properties: [ + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], + 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + 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->type === 'persistent') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]), + ], 422); + } + + $storage = LocalPersistentVolume::create([ + 'name' => $application->uuid.'-'.$request->name, + 'mount_path' => $request->mount_path, + 'host_path' => $request->host_path, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($mountPath, 'file storage path'); + + $fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-application-uuid', + 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') + ), + new OA\Parameter( + name: 'storage_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $storageUuid = $request->route('storage_uuid'); + + $storage = $application->persistentStorages->where('uuid', $storageUuid)->first(); + if (! $storage) { + $storage = $application->fileStorages->where('uuid', $storageUuid)->first(); + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 15d182db2..8e31a7051 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -11,11 +11,16 @@ use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; +use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; +use App\Support\ValidationPatterns; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -259,6 +264,7 @@ public function database_by_uuid(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -322,7 +328,7 @@ public function database_by_uuid(Request $request) )] public function update_by_uuid(Request $request) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); @@ -330,7 +336,7 @@ public function update_by_uuid(Request $request) // this check if the request is a valid json $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -339,6 +345,7 @@ public function update_by_uuid(Request $request) 'image' => 'string', 'is_public' => 'boolean', 'public_port' => 'numeric|nullable', + 'public_port_timeout' => 'integer|nullable|min:1', 'limits_memory' => 'string', 'limits_memory_swap' => 'string', 'limits_memory_swappiness' => 'numeric', @@ -370,7 +377,7 @@ public function update_by_uuid(Request $request) } switch ($database->type()) { case 'standalone-postgresql': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ 'postgres_user' => 'string', 'postgres_password' => 'string', @@ -401,20 +408,20 @@ public function update_by_uuid(Request $request) } break; case 'standalone-clickhouse': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', ]); break; case 'standalone-dragonfly': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => 'string', ]); break; case 'standalone-redis': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ 'redis_password' => 'string', 'redis_conf' => 'string', @@ -441,7 +448,7 @@ public function update_by_uuid(Request $request) } break; case 'standalone-keydb': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ 'keydb_password' => 'string', 'keydb_conf' => 'string', @@ -468,7 +475,7 @@ public function update_by_uuid(Request $request) } break; case 'standalone-mariadb': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', 'mariadb_root_password' => 'string', @@ -498,7 +505,7 @@ public function update_by_uuid(Request $request) } break; case 'standalone-mongodb': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => 'string', @@ -528,7 +535,7 @@ public function update_by_uuid(Request $request) break; case 'standalone-mysql': - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => 'string', 'mysql_password' => 'string', @@ -636,6 +643,7 @@ public function update_by_uuid(Request $request) 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), ) @@ -672,7 +680,7 @@ public function update_by_uuid(Request $request) )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -681,7 +689,7 @@ public function create_backup(Request $request) // Validate incoming request is valid JSON $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -699,6 +707,7 @@ public function create_backup(Request $request) 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { @@ -788,6 +797,18 @@ public function create_backup(Request $request) } } + // Validate databases_to_backup input + if (! empty($backupData['databases_to_backup'])) { + try { + validateDatabasesBackupInput($backupData['databases_to_backup']); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['databases_to_backup' => [$e->getMessage()]], + ], 422); + } + } + // Add required fields $backupData['database_id'] = $database->id; $backupData['database_type'] = $database->getMorphClass(); @@ -861,6 +882,7 @@ public function create_backup(Request $request) 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), ) @@ -890,7 +912,7 @@ public function create_backup(Request $request) )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -898,7 +920,7 @@ public function update_backup(Request $request) } // this check if the request is a valid json $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -908,13 +930,14 @@ public function update_backup(Request $request) 'dump_all' => 'boolean', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', 'databases_to_backup' => 'string|nullable', - 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', 'database_backup_retention_max_storage_locally' => 'integer|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { return response()->json([ @@ -941,6 +964,17 @@ public function update_backup(Request $request) $this->authorize('update', $database); + // Validate frequency is a valid cron expression + if ($request->filled('frequency')) { + $isValid = validate_cron_expression($request->frequency); + if (! $isValid) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + } + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { return response()->json([ 'message' => 'Validation failed.', @@ -993,6 +1027,18 @@ public function update_backup(Request $request) unset($backupData['s3_storage_uuid']); } + // Validate databases_to_backup input + if (! empty($backupData['databases_to_backup'])) { + try { + validateDatabasesBackupInput($backupData['databases_to_backup']); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['databases_to_backup' => [$e->getMessage()]], + ], 422); + } + } + $backupConfig->update($backupData); if ($request->backup_now) { @@ -1039,6 +1085,7 @@ public function update_backup(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1106,6 +1153,7 @@ public function create_database_postgresql(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1172,6 +1220,7 @@ public function create_database_clickhouse(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1239,6 +1288,7 @@ public function create_database_dragonfly(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1306,6 +1356,7 @@ public function create_database_redis(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1376,6 +1427,7 @@ public function create_database_keydb(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1446,6 +1498,7 @@ public function create_database_mariadb(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1513,6 +1566,7 @@ public function create_database_mysql(Request $request) 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'], @@ -1551,7 +1605,7 @@ public function create_database_mongodb(Request $request) public function create_database(Request $request, NewDatabaseTypes $type) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1562,7 +1616,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) $this->authorize('create', StandalonePostgresql::class); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -1641,6 +1695,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'destination_uuid' => 'string', 'is_public' => 'boolean', 'public_port' => 'numeric|nullable', + 'public_port_timeout' => 'integer|nullable|min:1', 'limits_memory' => 'string', 'limits_memory_swap' => 'string', 'limits_memory_swappiness' => 'numeric', @@ -1667,7 +1722,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } } if ($type === NewDatabaseTypes::POSTGRESQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ 'postgres_user' => 'string', 'postgres_password' => 'string', @@ -1711,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1726,7 +1781,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -1766,7 +1821,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1782,7 +1837,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => 'string', 'mysql_password' => 'string', @@ -1825,7 +1880,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1841,7 +1896,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ 'redis_password' => 'string', 'redis_conf' => 'string', @@ -1881,7 +1936,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1897,7 +1952,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => 'string', ]); @@ -1918,7 +1973,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1927,7 +1982,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'uuid' => $database->uuid, ]))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::KEYDB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ 'keydb_password' => 'string', 'keydb_conf' => 'string', @@ -1967,7 +2022,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1983,7 +2038,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => 'string', 'clickhouse_admin_password' => 'string', @@ -2003,7 +2058,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2019,7 +2074,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => 'string', @@ -2061,7 +2116,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2602,6 +2657,15 @@ public function action_deploy(Request $request) type: 'string', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -2653,7 +2717,9 @@ public function action_stop(Request $request) if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { return response()->json(['message' => 'Database is already stopped.'], 400); } - StopDatabase::dispatch($database); + + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopDatabase::dispatch($database, $dockerCleanup); return response()->json( [ @@ -2739,4 +2805,1070 @@ public function action_restart(Request $request) 200 ); } + + private function removeSensitiveEnvData($env) + { + $env->makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + if (request()->attributes->get('can_read_sensitive', false) === false) { + $env->makeHidden([ + 'value', + 'real_value', + ]); + } + + return serializeApiResponse($env); + } + + #[OA\Get( + summary: 'List Envs', + description: 'List all envs by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'list-envs-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment variables.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') + ) + ), + ] + ), + 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 envs(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $envs = $database->environment_variables->map(function ($env) { + return $this->removeSensitiveEnvData($env); + }); + + return response()->json($envs); + } + + #[OA\Patch( + summary: 'Update Env', + description: 'Update env by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'update-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Env updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['key', 'value'], + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + '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.'], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variable updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + ref: '#/components/schemas/EnvironmentVariable' + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_env_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $validator = customApiValidator($request->all(), [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $key = str($request->key)->trim()->replace(' ', '_')->value; + $env = $database->environment_variables()->where('key', $key)->first(); + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $env->value = $request->value; + if ($request->has('is_literal')) { + $env->is_literal = $request->is_literal; + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->is_multiline; + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->is_shown_once; + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } + $env->save(); + + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); + } + + #[OA\Patch( + summary: 'Update Envs (Bulk)', + description: 'Update multiple envs by database UUID.', + path: '/databases/{uuid}/envs/bulk', + operationId: 'update-envs-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Bulk envs updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['data'], + properties: [ + 'data' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + '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.'], + ], + ), + ], + ], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variables updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_bulk_envs(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $bulk_data = $request->get('data'); + if (! $bulk_data) { + return response()->json(['message' => 'Bulk data is required.'], 400); + } + + $updatedEnvs = collect(); + foreach ($bulk_data as $item) { + $validator = customApiValidator($item, [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + $key = str($item['key'])->trim()->replace(' ', '_')->value; + $env = $database->environment_variables()->updateOrCreate( + ['key' => $key], + $item + ); + + $updatedEnvs->push($this->removeSensitiveEnvData($env)); + } + + return response()->json($updatedEnvs)->setStatusCode(201); + } + + #[OA\Post( + summary: 'Create Env', + description: 'Create env by database UUID.', + path: '/databases/{uuid}/envs', + operationId: 'create-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Env created.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], + 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], + '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.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment variable created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'nc0k04gk8g0cgsk440g0koko'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_env(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $validator = customApiValidator($request->all(), [ + 'key' => 'string|required', + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $key = str($request->key)->trim()->replace(' ', '_')->value; + $existingEnv = $database->environment_variables()->where('key', $key)->first(); + if ($existingEnv) { + return response()->json([ + 'message' => 'Environment variable already exists. Use PATCH request to update it.', + ], 409); + } + + $env = $database->environment_variables()->create([ + 'key' => $key, + 'value' => $request->value, + 'is_literal' => $request->is_literal ?? false, + 'is_multiline' => $request->is_multiline ?? false, + 'is_shown_once' => $request->is_shown_once ?? false, + 'comment' => $request->comment ?? null, + ]); + + return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); + } + + #[OA\Delete( + summary: 'Delete Env', + description: 'Delete env by UUID.', + path: '/databases/{uuid}/envs/{env_uuid}', + operationId: 'delete-env-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'env_uuid', + in: 'path', + description: 'UUID of the environment variable.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment variable deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Environment variable deleted.'], + ] + ) + ), + ] + ), + 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 delete_env_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageEnvironment', $database); + + $env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) + ->where('resourceable_type', get_class($database)) + ->where('resourceable_id', $database->id) + ->first(); + + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $env->forceDelete(); + + return response()->json(['message' => 'Environment variable deleted.']); + } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by database UUID.', + path: '/databases/{uuid}/storages', + operationId: 'list-storages-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by database UUID.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')), + ], + ), + ), + 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 storages(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $persistentStorages = $database->persistentStorages->sortBy('id')->values(); + $fileStorages = $database->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for a database.', + path: '/databases/{uuid}/storages', + operationId: 'create-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path'], + properties: [ + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], + 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + 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->type === 'persistent') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]), + ], 422); + } + + $storage = LocalPersistentVolume::create([ + 'name' => $database->uuid.'-'.$request->name, + 'mount_path' => $request->mount_path, + 'host_path' => $request->host_path, + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($mountPath, 'file storage path'); + + $fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by database UUID.', + path: '/databases/{uuid}/storages', + operationId: 'update-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type'], + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'], + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'], + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], + 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 200, + description: 'Storage updated.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $validator = customApiValidator($request->all(), [ + 'uuid' => 'string', + 'id' => 'integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + ]); + + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + 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); + } + + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + + if ($request->type === 'persistent') { + $storage = $database->persistentStorages->where($lookupField, $lookupValue)->first(); + } else { + $storage = $database->fileStorages->where($lookupField, $lookupValue)->first(); + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + + // Always allowed + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + } else { + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + + $storage->save(); + + return response()->json($storage); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by database UUID.', + path: '/databases/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'storage_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $storageUuid = $request->route('storage_uuid'); + + $storage = $database->persistentStorages->where('uuid', $storageUuid)->first(); + if (! $storage) { + $storage = $database->fileStorages->where('uuid', $storageUuid)->first(); + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index baff3ec4f..6ff06c10a 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -4,12 +4,15 @@ use App\Actions\Database\StartDatabase; use App\Actions\Service\StartService; +use App\Enums\ApplicationDeploymentStatus; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\Service; use App\Models\Tag; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -127,6 +130,10 @@ public function deployment_by_uuid(Request $request) if (! $deployment) { return response()->json(['message' => 'Deployment not found.'], 404); } + $application = $deployment->application; + if (! $application || data_get($application->team(), 'id') !== (int) $teamId) { + return response()->json(['message' => 'Deployment not found.'], 404); + } return response()->json($this->removeSensitiveData($deployment)); } @@ -224,8 +231,8 @@ public function cancel_deployment(Request $request) // Check if deployment can be cancelled (must be queued or in_progress) $cancellableStatuses = [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + ApplicationDeploymentStatus::QUEUED->value, + ApplicationDeploymentStatus::IN_PROGRESS->value, ]; if (! in_array($deployment->status, $cancellableStatuses)) { @@ -242,11 +249,11 @@ public function cancel_deployment(Request $request) // Mark deployment as cancelled $deployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); // Get the server - $server = Server::find($build_server_id); + $server = Server::whereTeamId($teamId)->find($build_server_id); if ($server) { // Add cancellation log entry @@ -300,6 +307,8 @@ public function cancel_deployment(Request $request) new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')), new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')), ], responses: [ @@ -350,7 +359,9 @@ public function deploy(Request $request) $uuids = $request->input('uuid'); $tags = $request->input('tag'); $force = $request->input('force') ?? false; - $pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0; + $pullRequestId = $request->input('pull_request_id', $request->input('pr')); + $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; + $dockerTag = $request->string('docker_tag')->trim()->value() ?: null; if ($uuids && $tags) { return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400); @@ -358,16 +369,22 @@ public function deploy(Request $request) if ($tags && $pr) { return response()->json(['message' => 'You can only use tag or pr, not both.'], 400); } + if ($dockerTag && $pr === 0) { + return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400); + } + if ($dockerTag && $tags) { + return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400); + } if ($tags) { return $this->by_tags($tags, $teamId, $force); } elseif ($uuids) { - return $this->by_uuids($uuids, $teamId, $force, $pr); + return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag); } return response()->json(['message' => 'You must provide uuid or tag.'], 400); } - private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0) + private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null) { $uuids = explode(',', $uuid); $uuids = collect(array_filter($uuids)); @@ -380,15 +397,22 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p foreach ($uuids as $uuid) { $resource = getResourceByUuid($uuid, $teamId); if ($resource) { + $dockerTagForResource = $dockerTag; if ($pr !== 0) { - $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + $preview = null; + if ($resource instanceof Application && $resource->build_pack === 'dockerimage') { + $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag); + $dockerTagForResource = $preview?->docker_registry_image_tag; + } else { + $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; } } - $result = $this->deploy_resource($resource, $force, $pr); + $result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource); if (isset($result['status']) && $result['status'] === 429) { return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60); } @@ -461,7 +485,7 @@ public function by_tags(string $tags, int $team_id, bool $force = false) return response()->json(['message' => 'No resources found with this tag.'], 404); } - public function deploy_resource($resource, bool $force = false, int $pr = 0): array + public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array { $message = null; $deployment_uuid = null; @@ -473,9 +497,12 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar // Check authorization for application deployment try { $this->authorize('deploy', $resource); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null]; } + if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') { + return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null]; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $resource, @@ -483,6 +510,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar force_rebuild: $force, pull_request_id: $pr, is_api: true, + docker_registry_image_tag: $dockerTag, ); if ($result['status'] === 'queue_full') { return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429]; @@ -496,7 +524,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar // Check authorization for service deployment try { $this->authorize('deploy', $resource); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null]; } StartService::run($resource); @@ -506,7 +534,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar // Database resource - check authorization try { $this->authorize('manage', $resource); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null]; } StartDatabase::dispatch($resource); @@ -521,6 +549,34 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar return ['message' => $message, 'deployment_uuid' => $deployment_uuid]; } + private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview + { + $preview = $application->previews()->where('pull_request_id', $pullRequestId)->first(); + + if (! $preview && $dockerTag === null) { + return null; + } + + if (! $preview) { + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => '', + 'docker_registry_image_tag' => $dockerTag, + ]); + $preview->generate_preview_fqdn(); + + return $preview; + } + + if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) { + $preview->docker_registry_image_tag = $dockerTag; + $preview->save(); + } + + return $preview; + } + #[OA\Get( summary: 'List application deployments', description: 'List application deployments by using the app uuid', diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index f6a6b3513..9a2cf2b9f 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -5,6 +5,9 @@ use App\Http\Controllers\Controller; use App\Models\GithubApp; use App\Models\PrivateKey; +use App\Rules\SafeExternalUrl; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; @@ -181,7 +184,7 @@ public function create_github_app(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -204,8 +207,8 @@ public function create_github_app(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', 'organization' => 'nullable|string|max:255', - 'api_url' => 'required|string|url', - 'html_url' => 'required|string|url', + 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'nullable|string|max:255', 'custom_port' => 'nullable|integer|min:1|max:65535', 'app_id' => 'required|integer', @@ -370,7 +373,7 @@ public function load_repositories($github_app_id) return response()->json([ 'repositories' => $repositories->sortBy('name')->values(), ]); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + } catch (ModelNotFoundException $e) { return response()->json(['message' => 'GitHub app not found'], 404); } catch (\Throwable $e) { return handleError($e); @@ -472,7 +475,7 @@ public function load_branches($github_app_id, $owner, $repo) return response()->json([ 'branches' => $branches, ]); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + } catch (ModelNotFoundException $e) { return response()->json(['message' => 'GitHub app not found'], 404); } catch (\Throwable $e) { return handleError($e); @@ -587,10 +590,10 @@ public function update_github_app(Request $request, $github_app_id) $rules['organization'] = 'nullable|string'; } if (isset($payload['api_url'])) { - $rules['api_url'] = 'url'; + $rules['api_url'] = ['url', new SafeExternalUrl]; } if (isset($payload['html_url'])) { - $rules['html_url'] = 'url'; + $rules['html_url'] = ['url', new SafeExternalUrl]; } if (isset($payload['custom_user'])) { $rules['custom_user'] = 'string'; @@ -651,7 +654,7 @@ public function update_github_app(Request $request, $github_app_id) 'message' => 'GitHub app updated successfully', 'data' => $githubApp, ]); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + } catch (ModelNotFoundException $e) { return response()->json([ 'message' => 'GitHub app not found', ], 404); @@ -736,7 +739,7 @@ public function delete_github_app($github_app_id) return response()->json([ 'message' => 'GitHub app deleted successfully', ]); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + } catch (ModelNotFoundException $e) { return response()->json([ 'message' => 'GitHub app not found', ], 404); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2645c2df1..ed91b4475 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -586,7 +586,8 @@ public function createServer(Request $request) } // Check server limit - if (Team::serverLimitReached()) { + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index da553a68c..ec2e300ff 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -234,7 +235,7 @@ public function create_project(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = Validator::make($request->all(), [ @@ -347,7 +348,7 @@ public function update_project(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = Validator::make($request->all(), [ @@ -600,7 +601,7 @@ public function create_environment(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = Validator::make($request->all(), [ diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php new file mode 100644 index 000000000..6245dc2ec --- /dev/null +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -0,0 +1,922 @@ +makeHidden([ + 'id', + 'team_id', + 'application_id', + 'service_id', + ]); + + return serializeApiResponse($task); + } + + private function resolveApplication(Request $request, int $teamId): ?Application + { + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + } + + private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $tasks = $resource->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'command' => 'required|string', + 'frequency' => 'required|string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + $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 (! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $teamId = getTeamIdFromToken(); + + $task = new ScheduledTask; + $task->name = $request->name; + $task->command = $request->command; + $task->frequency = $request->frequency; + $task->container = $request->container; + $task->timeout = $request->has('timeout') ? $request->timeout : 300; + $task->enabled = $request->has('enabled') ? $request->enabled : true; + $task->team_id = $teamId; + + if ($resource instanceof Application) { + $task->application_id = $resource->id; + } elseif ($resource instanceof Service) { + $task->service_id = $resource->id; + } + + $task->save(); + + return response()->json($this->removeSensitiveData($task), 201); + } + + private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + if ($request->all() === []) { + return response()->json(['message' => 'At least one field must be provided.'], 422); + } + + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ + 'name' => 'string|max:255', + 'command' => 'string', + 'frequency' => 'string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + $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->has('frequency') && ! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->update($request->only($allowedFields)); + + return response()->json($this->removeSensitiveData($task), 200); + } + + private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); + if (! $deleted) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + return response()->json(['message' => 'Scheduled task deleted.']); + } + + private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $executions = $task->executions()->get()->map(function ($execution) { + $execution->makeHidden(['id', 'scheduled_task_id']); + + return serializeApiResponse($execution); + }); + + return response()->json($executions); + } + + #[OA\Get( + summary: 'List Tasks', + description: 'List all scheduled tasks for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for an application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->listTasks($application); + } + + #[OA\Post( + summary: 'Create Task', + description: 'Create a new scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->createTask($request, $application); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->updateTask($request, $application); + } + + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->deleteTask($request, $application); + } + + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all executions for a scheduled task.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->getExecutions($request, $application); + } + + #[OA\Get( + summary: 'List Tasks', + description: 'List all scheduled tasks for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for a service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->listTasks($service); + } + + #[OA\Post( + summary: 'Create Task', + description: 'Create a new scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->createTask($request, $service); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->updateTask($request, $service); + } + + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->deleteTask($request, $service); + } + + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all executions for a scheduled task.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->getExecutions($request, $service); + } +} diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e7b36cb9a..2c62928c2 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\PrivateKey; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -176,7 +177,7 @@ public function create_key(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -300,7 +301,7 @@ public function update_key(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -330,7 +331,7 @@ public function update_key(Request $request) 'message' => 'Private Key not found.', ], 404); } - $foundKey->update($request->all()); + $foundKey->update($request->only($allowedFields)); return response()->json(serializeApiResponse([ 'uuid' => $foundKey->uuid, diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ee5455b6..c13c6665c 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -7,10 +7,12 @@ use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; use App\Http\Controllers\Controller; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server as ModelsServer; +use App\Rules\ValidServerIp; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Stringable; @@ -288,15 +290,24 @@ public function domains_by_server(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } - $uuid = $request->get('uuid'); + $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (is_null($server)) { + return response()->json(['message' => 'Server not found.'], 404); + } + $uuid = $request->query('uuid'); if ($uuid) { - $domains = Application::getDomainsByUuid($uuid); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } - return response()->json(serializeApiResponse($domains)); + return response()->json(serializeApiResponse($application->fqdns)); } $projects = Project::where('team_id', $teamId)->get(); $domains = collect(); - $applications = $projects->pluck('applications')->flatten(); + $applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) { + return $application->destination?->server?->id === $server->id; + }); $settings = instanceSettings(); if ($applications->count() > 0) { foreach ($applications as $application) { @@ -336,7 +347,9 @@ public function domains_by_server(Request $request) } } } - $services = $projects->pluck('services')->flatten(); + $services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) { + return $service->server_id === $server->id; + }); if ($services->count() > 0) { foreach ($services as $service) { $service_applications = $service->applications; @@ -349,7 +362,8 @@ public function domains_by_server(Request $request) })->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); - if ($ip === 'host.docker.internal') { + $serviceIp = $server->ip; + if ($serviceIp === 'host.docker.internal') { if ($settings->public_ipv4) { $domains->push([ 'domain' => $fqdn, @@ -365,13 +379,13 @@ public function domains_by_server(Request $request) if (! $settings->public_ipv4 && ! $settings->public_ipv6) { $domains->push([ 'domain' => $fqdn, - 'ip' => $ip, + 'ip' => $serviceIp, ]); } } else { $domains->push([ 'domain' => $fqdn, - 'ip' => $ip, + 'ip' => $serviceIp, ]); } } @@ -469,10 +483,10 @@ public function create_server(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'description' => 'string|nullable', - 'ip' => 'string|required', - 'port' => 'integer|nullable', + 'ip' => ['string', 'required', new ValidServerIp], + 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', - 'user' => 'string|nullable', + 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', @@ -519,9 +533,13 @@ public function create_server(Request $request) if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $allServers = ModelsServer::whereIp($request->ip)->get(); - if ($allServers->count() > 0) { - return response()->json(['message' => 'Server with this IP already exists.'], 400); + $foundServer = ModelsServer::whereIp($request->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === $teamId) { + return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); + } + + return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; @@ -580,6 +598,11 @@ public function create_server(Request $request) 'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], + 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], + 'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'], + 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], + 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], + 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], ], ), ), @@ -616,7 +639,7 @@ public function create_server(Request $request) )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -630,13 +653,18 @@ public function update_server(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255|nullable', 'description' => 'string|nullable', - 'ip' => 'string|nullable', - 'port' => 'integer|nullable', + 'ip' => ['string', 'nullable', new ValidServerIp], + 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', - 'user' => 'string|nullable', + 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', + 'concurrent_builds' => 'integer|min:1', + 'dynamic_timeout' => 'integer|min:1', + 'deployment_queue_limit' => 'integer|min:1', + 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', + 'server_disk_usage_check_frequency' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -673,6 +701,19 @@ public function update_server(Request $request) 'is_build_server' => $request->is_build_server, ]); } + + if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']], + ], 422); + } + + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); + if (! empty($advancedSettings)) { + $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); + } + if ($request->instant_validate) { ValidateServer::dispatch($server); } @@ -750,12 +791,22 @@ public function delete_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } - if ($server->definedResources()->count() > 0) { - return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); + + $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN); + + if ($server->definedResources()->count() > 0 && ! $force) { + return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400); } if ($server->isLocalhost()) { return response()->json(['message' => 'Local server cannot be deleted.'], 400); } + + if ($force) { + foreach ($server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $server->delete(); DeleteServer::dispatch( $server->id, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 27fdb1ba8..23ba30998 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -8,9 +8,13 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\Server; use App\Models\Service; +use App\Support\ValidationPatterns; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -217,11 +221,12 @@ public function services(Request $request) type: 'object', properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], - 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'], + 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'], ], ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ), ), @@ -288,7 +293,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -298,7 +303,7 @@ public function create_service(Request $request) $this->authorize('create', Service::class); $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validationRules = [ @@ -317,6 +322,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -377,6 +383,17 @@ public function create_service(Request $request) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } $services = get_service_templates(); $serviceKeys = $services->keys(); if ($serviceKeys->contains($request->type)) { @@ -418,6 +435,9 @@ public function create_service(Request $request) $service = Service::create($servicePayload); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); if ($oneClickDotEnvs?->count() > 0) { $oneClickDotEnvs->each(function ($value) use ($service) { @@ -474,7 +494,7 @@ public function create_service(Request $request) 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', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'project_uuid' => 'string|required', @@ -492,6 +512,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -543,6 +564,17 @@ public function create_service(Request $request) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } if (! isBase64Encoded($request->docker_compose_raw)) { return response()->json([ 'message' => 'Validation failed.', @@ -587,6 +619,9 @@ public function create_service(Request $request) $service->destination_id = $destination->id; $service->destination_type = $destination->getMorphClass(); $service->connect_to_docker_network = $connectToDockerNetwork; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(isNew: true); @@ -808,11 +843,12 @@ public function delete_by_uuid(Request $request) type: 'object', properties: [ 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], - 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'], + 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'], ], ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ) ), @@ -890,7 +926,7 @@ public function update_by_uuid(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -901,7 +937,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -914,6 +950,7 @@ public function update_by_uuid(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -979,6 +1016,9 @@ public function update_by_uuid(Request $request) if ($request->has('connect_to_docker_network')) { $service->connect_to_docker_network = $request->connect_to_docker_network; } + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(); @@ -1141,10 +1181,7 @@ public function envs(Request $request) new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'], - ] + ref: '#/components/schemas/EnvironmentVariable' ) ), ] @@ -1174,7 +1211,7 @@ public function update_env_by_uuid(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1187,6 +1224,7 @@ public function update_env_by_uuid(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1202,7 +1240,19 @@ public function update_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->fill($request->all()); + $env->value = $request->value; + if ($request->has('is_literal')) { + $env->is_literal = $request->is_literal; + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->is_multiline; + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->is_shown_once; + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -1265,10 +1315,8 @@ public function update_env_by_uuid(Request $request) new OA\MediaType( mediaType: 'application/json', schema: new OA\Schema( - type: 'object', - properties: [ - 'message' => ['type' => 'string', 'example' => 'Environment variables updated.'], - ] + type: 'array', + items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') ) ), ] @@ -1298,7 +1346,7 @@ public function create_bulk_envs(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1318,6 +1366,7 @@ public function create_bulk_envs(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1417,7 +1466,7 @@ public function create_env(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } @@ -1430,6 +1479,7 @@ public function create_env(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1447,7 +1497,14 @@ public function create_env(Request $request) ], 409); } - $env = $service->environment_variables()->create($request->all()); + $env = $service->environment_variables()->create([ + 'key' => $key, + 'value' => $request->value, + 'is_literal' => $request->is_literal ?? false, + 'is_multiline' => $request->is_multiline ?? false, + 'is_shown_once' => $request->is_shown_once ?? false, + 'comment' => $request->comment ?? null, + ]); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1518,14 +1575,14 @@ public function delete_env_by_uuid(Request $request) return invalidTokenResponse(); } - $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } $this->authorize('manageEnvironment', $service); - $env = EnvironmentVariable::where('uuid', $request->env_uuid) + $env = EnvironmentVariable::where('uuid', $request->route('env_uuid')) ->where('resourceable_type', Service::class) ->where('resourceable_id', $service->id) ->first(); @@ -1638,6 +1695,15 @@ public function action_deploy(Request $request) type: 'string', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -1689,7 +1755,9 @@ public function action_stop(Request $request) if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) { return response()->json(['message' => 'Service is already stopped.'], 400); } - StopService::dispatch($service); + + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopService::dispatch($service, false, $dockerCleanup); return response()->json( [ @@ -1785,4 +1853,609 @@ public function action_restart(Request $request) 200 ); } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by service UUID.', + path: '/services/{uuid}/storages', + operationId: 'list-storages-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by service UUID.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')), + ], + ), + ), + 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 storages(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + + if (! $service) { + return response()->json([ + 'message' => 'Service not found.', + ], 404); + } + + $this->authorize('view', $service); + + $persistentStorages = collect(); + $fileStorages = collect(); + + foreach ($service->applications as $app) { + $persistentStorages = $persistentStorages->merge( + $app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application')) + ); + $fileStorages = $fileStorages->merge( + $app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application')) + ); + } + foreach ($service->databases as $db) { + $persistentStorages = $persistentStorages->merge( + $db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database')) + ); + $fileStorages = $fileStorages->merge( + $db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database')) + ); + } + + return response()->json([ + 'persistent_storages' => $persistentStorages->sortBy('id')->values(), + 'file_storages' => $fileStorages->sortBy('id')->values(), + ]); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for a service sub-resource.', + path: '/services/{uuid}/storages', + operationId: 'create-storage-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path', 'resource_uuid'], + properties: [ + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], + 'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'], + 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'resource_uuid' => 'required|string', + 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + 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); + } + + $subResource = $service->applications()->where('uuid', $request->resource_uuid)->first(); + if (! $subResource) { + $subResource = $service->databases()->where('uuid', $request->resource_uuid)->first(); + } + if (! $subResource) { + return response()->json(['message' => 'Service resource not found.'], 404); + } + + if ($request->type === 'persistent') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]), + ], 422); + } + + $storage = LocalPersistentVolume::create([ + 'name' => $subResource->uuid.'-'.$request->name, + 'mount_path' => $request->mount_path, + 'host_path' => $request->host_path, + 'resource_id' => $subResource->id, + 'resource_type' => $subResource->getMorphClass(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($mountPath, 'file storage path'); + + $fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by service UUID.', + path: '/services/{uuid}/storages', + operationId: 'update-storage-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type'], + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'], + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'], + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], + 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], + 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 200, + description: 'Storage updated.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); + + if (! $service) { + return response()->json([ + 'message' => 'Service not found.', + ], 404); + } + + $this->authorize('update', $service); + + $validator = customApiValidator($request->all(), [ + 'uuid' => 'string', + 'id' => 'integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + ]); + + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + 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); + } + + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + + $storage = null; + if ($request->type === 'persistent') { + foreach ($service->applications as $app) { + $storage = $app->persistentStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->persistentStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + } + } else { + foreach ($service->applications as $app) { + $storage = $app->fileStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->fileStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + } + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + + // Always allowed + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + } else { + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + + $storage->save(); + + return response()->json($storage); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by service UUID.', + path: '/services/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'storage_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + $storageUuid = $request->route('storage_uuid'); + + $storage = null; + foreach ($service->applications as $app) { + $storage = $app->persistentStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->persistentStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + } + if (! $storage) { + foreach ($service->applications as $app) { + $storage = $app->fileStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->fileStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index fd0282d96..03b36e4e0 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -14,14 +14,6 @@ private function removeSensitiveData($team) 'custom_server_limit', 'pivot', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $team->makeHidden([ - 'smtp_username', - 'smtp_password', - 'resend_api_key', - 'telegram_token', - ]); - } return serializeApiResponse($team); } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 09007ad96..17d14296b 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -108,9 +108,31 @@ public function link() return redirect()->route('login')->with('error', 'Invalid credentials.'); } + public function showInvitation() + { + $invitationUuid = request()->route('uuid'); + $invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail(); + $user = User::whereEmail($invitation->email)->firstOrFail(); + + if (Auth::id() !== $user->id) { + abort(400, 'You are not allowed to accept this invitation.'); + } + + if (! $invitation->isValid()) { + abort(400, 'Invitation expired.'); + } + + $alreadyMember = $user->teams()->where('team_id', $invitation->team->id)->exists(); + + return view('invitation.accept', [ + 'invitation' => $invitation, + 'team' => $invitation->team, + 'alreadyMember' => $alreadyMember, + ]); + } + public function acceptInvitation() { - $resetPassword = request()->query('reset-password'); $invitationUuid = request()->route('uuid'); $invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail(); @@ -119,43 +141,21 @@ public function acceptInvitation() if (Auth::id() !== $user->id) { abort(400, 'You are not allowed to accept this invitation.'); } - $invitationValid = $invitation->isValid(); - if ($invitationValid) { - if ($resetPassword) { - $user->update([ - 'password' => Hash::make($invitationUuid), - 'force_password_reset' => true, - ]); - } - if ($user->teams()->where('team_id', $invitation->team->id)->exists()) { - $invitation->delete(); - - return redirect()->route('team.index'); - } - $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); - $invitation->delete(); - - refreshSession($invitation->team); - - return redirect()->route('team.index'); - } else { + if (! $invitation->isValid()) { abort(400, 'Invitation expired.'); } - } - public function revokeInvitation() - { - $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail(); - $user = User::whereEmail($invitation->email)->firstOrFail(); - if (is_null(Auth::user())) { - return redirect()->route('login'); - } - if (Auth::id() !== $user->id) { - abort(401); + if ($user->teams()->where('team_id', $invitation->team->id)->exists()) { + $invitation->delete(); + + return redirect()->route('team.index'); } + $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); $invitation->delete(); + refreshSession($invitation->team); + return redirect()->route('team.index'); } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index e5a5b746e..fe49369ea 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -55,6 +55,9 @@ public function manual(Request $request) $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); } + if (! in_array($x_github_event, ['push', 'pull_request'])) { + return response("Nothing to do. Event '$x_github_event' is not supported."); + } if (! $branch) { return response('Nothing to do. No branch found in the request.'); } @@ -246,6 +249,9 @@ public function normal(Request $request) $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); } + if (! in_array($x_github_event, ['push', 'pull_request'])) { + return response("Nothing to do. Event '$x_github_event' is not supported."); + } if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); } diff --git a/app/Http/Middleware/CheckForcePasswordReset.php b/app/Http/Middleware/CheckForcePasswordReset.php index 78b1f896c..c857cb836 100644 --- a/app/Http/Middleware/CheckForcePasswordReset.php +++ b/app/Http/Middleware/CheckForcePasswordReset.php @@ -25,7 +25,7 @@ public function handle(Request $request, Closure $next): Response } $force_password_reset = auth()->user()->force_password_reset; if ($force_password_reset) { - if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') { + if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') { return $next($request); } diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index f0b9d67f2..5fca583d9 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -91,6 +91,13 @@ public function hosts(): array // Trust all subdomains of APP_URL as fallback $trustedHosts[] = $this->allSubdomainsOfApplicationUrl(); + // Always trust loopback addresses so local access works even when FQDN is configured + foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) { + if (! in_array($localHost, $trustedHosts, true)) { + $trustedHosts[] = $localHost; + } + } + return array_filter($trustedHosts); } } diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 559dd2fc3..a4764047b 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -25,4 +25,26 @@ class TrustProxies extends Middleware Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Handle the request. + * + * Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed), + * the Secure cookie flag is auto-enabled when the request is over HTTPS. + * This ensures session cookies are correctly marked Secure when behind an HTTPS + * reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE + * is not explicitly set in .env. + */ + public function handle($request, \Closure $next) + { + return parent::handle($request, function ($request) use ($next) { + // At this point proxy headers have been applied to the request, + // so $request->secure() correctly reflects the actual protocol. + if ($request->secure() && config('session.secure') === null) { + config(['session.secure' => true]); + } + + return $next($request); + }); + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index eaee7e221..7e5025c8a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; use Carbon\Carbon; @@ -75,6 +76,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private ?string $dockerImageTag = null; + private ?string $dockerImagePreviewTag = null; + private GithubApp|GitlabApp|string $source = 'other'; private StandaloneDocker|SwarmDocker $destination; @@ -171,6 +174,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerBuildkitSupported = false; + private bool $dockerSecretsSupported = false; + private bool $skip_build = false; private Collection|string $build_secrets; @@ -205,6 +210,7 @@ public function __construct(public int $application_deployment_queue_id) $this->restart_only = $this->application_deployment_queue->restart_only; $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->only_this_server = $this->application_deployment_queue->only_this_server; + $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; $this->git_type = data_get($this->application_deployment_queue, 'git_type'); @@ -221,7 +227,11 @@ public function __construct(public int $application_deployment_queue_id) $this->preserveRepository = $this->application->settings->is_preserve_repository_enabled; $this->basedir = $this->application->generateBaseDir($this->deployment_uuid); - $this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/'); + $baseDir = $this->application->base_directory; + if ($baseDir && $baseDir !== '/') { + $this->validatePathField($baseDir, 'base_directory'); + } + $this->workdir = "{$this->basedir}".rtrim($baseDir, '/'); $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->is_debug_enabled = $this->application->settings->is_debug_enabled; @@ -239,6 +249,9 @@ public function __construct(public int $application_deployment_queue_id) // Set preview fqdn if ($this->pull_request_id !== 0) { $this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id); + if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) { + $this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag; + } if ($this->preview) { if ($this->application->build_pack === 'dockercompose') { $this->preview->generate_preview_fqdn_compose(); @@ -251,7 +264,7 @@ public function __construct(public int $application_deployment_queue_id) } if ($this->application->build_pack === 'dockerfile') { if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } } } @@ -281,7 +294,8 @@ public function handle(): void // 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); + $safeNetwork = escapeshellarg($this->destination->network); + $allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server); if (! is_null($allContainers)) { $allContainers = format_docker_command_output_to_json($allContainers); @@ -310,7 +324,11 @@ public function handle(): void } if ($this->application->dockerfile_target_build) { - $this->buildTarget = " --target {$this->application->dockerfile_target_build} "; + $target = $this->application->dockerfile_target_build; + if (! preg_match(ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) { + throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.'); + } + $this->buildTarget = " --target {$target} "; } // Check custom port @@ -381,13 +399,6 @@ public function handle(): void 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})"; @@ -403,53 +414,55 @@ private function detectBuildKitCapabilities(): void 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."); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+)."); return; } - $buildkitEnabled = instant_remote_process( + // Check buildx availability (always installed by Coolify on Docker 24.0+) + $buildxAvailable = instant_remote_process( ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], $serverToCheck ); - if (trim($buildkitEnabled) !== 'available') { + if (trim($buildxAvailable) === 'available') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + } else { + // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag $buildkitTest = instant_remote_process( - ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && 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.'); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}."); } 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.'); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited."); } - } else { - // Buildx is available, which means BuildKit is available - // Now specifically test for secrets support + } + + // If build secrets are enabled and BuildKit is available, verify --secret flag support + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { $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->dockerSecretsSupported = true; $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.'); + $this->dockerSecretsSupported = false; + $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dockerBuildkitSupported = false; + $this->dockerSecretsSupported = 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.'); } } @@ -459,14 +472,14 @@ private function decide_what_to_do() $this->just_restart(); return; + } elseif ($this->application->build_pack === 'dockerimage') { + $this->deploy_dockerimage_buildpack(); } elseif ($this->pull_request_id !== 0) { $this->deploy_pull_request(); } elseif ($this->application->dockerfile) { $this->deploy_simple_dockerfile(); } elseif ($this->application->build_pack === 'dockercompose') { $this->deploy_docker_compose_buildpack(); - } elseif ($this->application->build_pack === 'dockerimage') { - $this->deploy_dockerimage_buildpack(); } elseif ($this->application->build_pack === 'dockerfile') { $this->deploy_dockerfile_buildpack(); } elseif ($this->application->build_pack === 'static') { @@ -486,7 +499,7 @@ private function post_deployment() // Then handle side effects - these should not fail the deployment try { GetContainersStatus::dispatch($this->server); - } catch (\Exception $e) { + } catch (Exception $e) { \Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } @@ -494,7 +507,7 @@ private function post_deployment() if ($this->application->is_github_based()) { try { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED); - } catch (\Exception $e) { + } catch (Exception $e) { \Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } } @@ -502,13 +515,13 @@ private function post_deployment() try { $this->run_post_deployment_command(); - } catch (\Exception $e) { + } catch (Exception $e) { \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); } try { $this->application->isConfigurationChanged(true); - } catch (\Exception $e) { + } catch (Exception $e) { \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } } @@ -547,11 +560,7 @@ private function deploy_simple_dockerfile() private function deploy_dockerimage_buildpack() { $this->dockerImage = $this->application->docker_registry_image_name; - if (str($this->application->docker_registry_image_tag)->isEmpty()) { - $this->dockerImageTag = 'latest'; - } else { - $this->dockerImageTag = $this->application->docker_registry_image_tag; - } + $this->dockerImageTag = $this->resolveDockerImageTag(); // Check if this is an image hash deployment $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); @@ -568,18 +577,34 @@ private function deploy_dockerimage_buildpack() $this->rolling_update(); } + private function resolveDockerImageTag(): string + { + if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) { + return $this->dockerImagePreviewTag; + } + + if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { + return $this->application->docker_registry_image_tag; + } + + return 'latest'; + } + private function deploy_docker_compose_buildpack() { if (data_get($this->application, 'docker_compose_location')) { - $this->docker_compose_location = $this->application->docker_compose_location; + $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { + $this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command'); $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) { - $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); + $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; + $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); } } if (data_get($this->application, 'docker_compose_custom_build_command')) { + $this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command'); $this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command; if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) { $this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); @@ -632,7 +657,7 @@ private function deploy_docker_compose_buildpack() // 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)) { + if ($this->dockerSecretsSupported && ! 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 { @@ -653,7 +678,7 @@ private function deploy_docker_compose_buildpack() } // 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)) { + if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $composeFile = $this->add_build_secrets_to_compose($composeFile); } @@ -687,10 +712,8 @@ private function deploy_docker_compose_buildpack() } // Inject build arguments after build subcommand if not using build secrets - if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); - // Escape single quotes for bash -c context used by executeInDocker - $build_args_string = str_replace("'", "'\\''", $build_args_string); // Inject build args right after 'build' subcommand (not at the end) $original_command = $build_command; @@ -702,9 +725,17 @@ private function deploy_docker_compose_buildpack() } } - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], - ); + try { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], + ); + } catch (\RuntimeException $e) { + if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { + throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}"); + } + + throw $e; + } } else { $command = "{$this->coolify_variables} docker compose"; // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported @@ -719,10 +750,8 @@ private function deploy_docker_compose_buildpack() $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()) { + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); - // Escape single quotes for bash -c context used by executeInDocker - $build_args_string = str_replace("'", "'\\''", $build_args_string); $command .= " {$build_args_string}"; $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); } @@ -768,9 +797,18 @@ private function deploy_docker_compose_buildpack() ); $this->write_deployment_configurations(); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true], - ); + + try { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + ); + } catch (\RuntimeException $e) { + if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { + throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}"); + } + + throw $e; + } } else { $this->write_deployment_configurations(); $this->docker_compose_location = '/docker-compose.yaml'; @@ -780,7 +818,7 @@ private function deploy_docker_compose_buildpack() $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( - ['command' => $command, 'hidden' => true], + ['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } } else { @@ -795,9 +833,15 @@ private function deploy_docker_compose_buildpack() ); $this->write_deployment_configurations(); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true], - ); + if ($this->preserveRepository) { + $this->execute_remote_command( + ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + ); + } else { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + ); + } } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { @@ -807,14 +851,14 @@ private function deploy_docker_compose_buildpack() $this->write_deployment_configurations(); $this->execute_remote_command( - ['command' => $command, 'hidden' => true], + ['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } else { // Always use .env file $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], + [executeInDocker($this->deployment_uuid, $command), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); $this->write_deployment_configurations(); } @@ -831,7 +875,7 @@ private function deploy_dockerfile_buildpack() $this->server = $this->build_server; } if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } $this->prepare_builder_image(); $this->check_git_if_build_needed(); @@ -1086,10 +1130,21 @@ private function generate_image_names() private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); + + // Restart doesn't need the build server — disable it so the helper container + // is created on the deployment server with the correct network/flags. + $originalUseBuildServer = $this->use_build_server; + $this->use_build_server = false; + $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->check_image_locally_or_remotely(); + + // Restore before should_skip_build() — it may re-enter decide_what_to_do() + // for a full rebuild which needs the build server. + $this->use_build_server = $originalUseBuildServer; + $this->should_skip_build(); $this->completeDeployment(); } @@ -1227,7 +1282,7 @@ private function generate_runtime_environment_variables() }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$env->real_value); + $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); } // Check for PORT environment variable mismatch with ports_exposes @@ -1293,8 +1348,24 @@ private function generate_runtime_environment_variables() }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$env->real_value); + $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); } + + // Fall back to production env vars for keys not overridden by preview vars, + // but only when preview vars are configured. This ensures variables like + // DB_PASSWORD that are only set for production will be available in the + // preview .env file (fixing ${VAR} interpolation in docker-compose YAML), + // while avoiding leaking production values when previews aren't configured. + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + } + } + // 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()) { @@ -1533,10 +1604,11 @@ private function generate_buildtime_environment_variables() } foreach ($sorted_environment_variables as $env) { + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { // Strip outer quotes from real_value and apply proper bash escaping - $value = trim($env->real_value, "'"); + $value = trim($resolvedValue, "'"); $escapedValue = escapeBashEnvValue($value); if (isDev() && isset($envs_dict[$env->key])) { @@ -1548,13 +1620,13 @@ private function generate_buildtime_environment_variables() if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); - $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$resolvedValue}"); $this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}"); $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); } } else { // For normal vars, use double quotes to allow $VAR expansion - $escapedValue = escapeBashDoubleQuoted($env->real_value); + $escapedValue = escapeBashDoubleQuoted($resolvedValue); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); @@ -1565,7 +1637,7 @@ private function generate_buildtime_environment_variables() if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); - $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$resolvedValue}"); $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); } } @@ -1584,10 +1656,11 @@ private function generate_buildtime_environment_variables() } foreach ($sorted_environment_variables as $env) { + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { // Strip outer quotes from real_value and apply proper bash escaping - $value = trim($env->real_value, "'"); + $value = trim($resolvedValue, "'"); $escapedValue = escapeBashEnvValue($value); if (isDev() && isset($envs_dict[$env->key])) { @@ -1599,13 +1672,13 @@ private function generate_buildtime_environment_variables() if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); - $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$resolvedValue}"); $this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}"); $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); } } else { // For normal vars, use double quotes to allow $VAR expansion - $escapedValue = escapeBashDoubleQuoted($env->real_value); + $escapedValue = escapeBashDoubleQuoted($resolvedValue); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); @@ -1616,7 +1689,7 @@ private function generate_buildtime_environment_variables() if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); - $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$resolvedValue}"); $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); } } @@ -1800,7 +1873,8 @@ private function health_check() $counter = 1; $this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.'); if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) { - $this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}"); + $healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL'; + $this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}"); } $this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck."); $sleeptime = 0; @@ -1877,6 +1951,11 @@ private function query_logs() private function deploy_pull_request() { + if ($this->application->build_pack === 'dockerimage') { + $this->deploy_dockerimage_buildpack(); + + return; + } if ($this->application->build_pack === 'dockercompose') { $this->deploy_docker_compose_buildpack(); @@ -1959,9 +2038,11 @@ private function prepare_builder_image(bool $firstTry = true) $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} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $safeNetwork = escapeshellarg($this->destination->network); + $runCommand = "docker run -d --network {$safeNetwork} --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} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $safeNetwork = escapeshellarg($this->destination->network); + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } if ($firstTry) { @@ -2073,7 +2154,7 @@ private function set_coolify_variables() private function check_git_if_build_needed() { - if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) { + if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { $repository = githubApi($this->source, "repos/{$this->customRepository}"); $data = data_get($repository, 'data'); $repository_project_id = data_get($data, 'id'); @@ -2116,7 +2197,7 @@ private function check_git_if_build_needed() 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} {$lsRemoteRef}"), + 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 -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -2179,7 +2260,7 @@ private function clone_repository() $this->create_workdir(); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"), + executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'), 'hidden' => true, 'save' => 'commit_message', ] @@ -2295,13 +2376,13 @@ private function nixpacks_build_cmd() $this->generate_nixpacks_env_variables(); $nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}"; if ($this->application->build_command) { - $nixpacks_command .= " --build-cmd \"{$this->application->build_command}\""; + $nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } if ($this->application->start_command) { - $nixpacks_command .= " --start-cmd \"{$this->application->start_command}\""; + $nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->application->install_command) { - $nixpacks_command .= " --install-cmd \"{$this->application->install_command}\""; + $nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command); } $nixpacks_command .= " {$this->workdir}"; @@ -2313,14 +2394,18 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = collect([]); if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { - if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + if (! is_null($resolvedValue) && $resolvedValue !== '') { + $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue; + $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } else { foreach ($this->application->nixpacks_environment_variables_preview as $env) { - if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + if (! is_null($resolvedValue) && $resolvedValue !== '') { + $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue; + $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } @@ -2330,7 +2415,7 @@ private function generate_nixpacks_env_variables() $coolify_envs->each(function ($value, $key) { // Only add environment variables with non-null and non-empty values if (! is_null($value) && $value !== '') { - $this->env_nixpacks_args->push("--env {$key}={$value}"); + $this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}")); } }); @@ -2445,7 +2530,9 @@ private function generate_env_variables() $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->env_args->put($key, $value); + if (! is_null($value) && $value !== '') { + $this->env_args->put($key, $value); + } }); // For build process, include only environment variables where is_buildtime = true @@ -2456,8 +2543,9 @@ private function generate_env_variables() ->get(); foreach ($envs as $env) { - if (! is_null($env->real_value)) { - $this->env_args->put($env->key, $env->real_value); + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + if (! is_null($resolvedValue)) { + $this->env_args->put($env->key, $resolvedValue); } } } else { @@ -2467,8 +2555,9 @@ private function generate_env_variables() ->get(); foreach ($envs as $env) { - if (! is_null($env->real_value)) { - $this->env_args->put($env->key, $env->real_value); + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + if (! is_null($resolvedValue)) { + $this->env_args->put($env->key, $resolvedValue); } } } @@ -2725,7 +2814,8 @@ private function generate_local_persistent_volumes() } else { $volume_name = $persistentStorage->name; } - if ($this->pull_request_id !== 0) { + $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); + if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; @@ -2743,7 +2833,8 @@ private function generate_local_persistent_volumes_only_volume_names() } $name = $persistentStorage->name; - if ($this->pull_request_id !== 0) { + $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); + if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } @@ -2758,29 +2849,62 @@ private function generate_local_persistent_volumes_only_volume_names() private function generate_healthcheck_commands() { + // Handle CMD type healthcheck + if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { + $command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command); + + // Defense in depth: validate command at runtime (matches input validation regex) + if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) { + $this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.'); + } else { + $this->full_healthcheck_url = $command; + + return $command; + } + } + + // HTTP type healthcheck (default) if (! $this->application->health_check_port) { - $health_check_port = $this->application->ports_exposes_array[0]; + $health_check_port = (int) $this->application->ports_exposes_array[0]; } else { - $health_check_port = $this->application->health_check_port; + $health_check_port = (int) $this->application->health_check_port; } if ($this->application->settings->is_static || $this->application->build_pack === 'static') { $health_check_port = 80; } - if ($this->application->health_check_path) { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}"; - $generated_healthchecks_commands = [ - "curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1", - ]; + + $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET'); + $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); + $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost'); + $path = $this->application->health_check_path + ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/') + : null; + + $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/')); + $escapedMethod = escapeshellarg($method); + + if ($path) { + $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}"; } else { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/"; - $generated_healthchecks_commands = [ - "curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1", - ]; + $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/"; } + $generated_healthchecks_commands = [ + "curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", + ]; + return implode(' ', $generated_healthchecks_commands); } + private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string + { + if (preg_match($pattern, $value)) { + return $value; + } + + return $default; + } + private function pull_latest_image($image) { $this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry."); @@ -2817,7 +2941,11 @@ private function build_static_image() $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}"; + if ($this->dockerBuildkitSupported) { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -2857,21 +2985,19 @@ private function wrap_build_command_with_env_export(string $build_command): stri private function build_image() { // 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 { + if (! $this->dockerSecretsSupported) { // Traditional build args approach - generate COOLIFY_ variables locally - // Generate COOLIFY_ variables locally for build args $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { $this->build_args->push("--build-arg '{$key}'"); }); - $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection - ? $this->build_args->implode(' ') - : (string) $this->build_args; } + // Always convert build_args Collection to string for command interpolation + $this->build_args = $this->build_args instanceof Collection + ? $this->build_args->implode(' ') + : (string) $this->build_args; + $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { $this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.'); @@ -2899,7 +3025,7 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // 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}" : ''; @@ -2907,9 +3033,8 @@ private function build_image() } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets $build_command = $this->wrap_build_command_with_env_export("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}"); - ray($build_command); } else { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -2919,18 +3044,16 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // 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 = $this->wrap_build_command_with_env_export("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}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } @@ -2952,30 +3075,29 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + $safeNetwork = escapeshellarg($this->destination->network); + if ($this->dockerSecretsSupported) { // 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 = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without 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 = $this->wrap_build_command_with_env_export("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->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("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->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3010,7 +3132,11 @@ private function build_image() $nginx_config = base64_encode(defaultNginxConfiguration()); } } - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + if ($this->dockerBuildkitSupported) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); + } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -3035,7 +3161,7 @@ private function build_image() } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // 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}" : ''; @@ -3044,12 +3170,19 @@ private function build_image() } 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 { - // Traditional build with args + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } + } else { + // Traditional build with args (no --progress for legacy builder compatibility) + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3079,18 +3212,16 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // 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 = $this->wrap_build_command_with_env_export("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}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = $this->wrap_build_command_with_env_export("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->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 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 { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -3100,18 +3231,16 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // 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 = $this->wrap_build_command_with_env_export("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}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3132,7 +3261,7 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); // Use BuildKit with secrets @@ -3144,19 +3273,17 @@ private function build_image() } } elseif ($this->dockerBuildkitSupported) { // BuildKit without 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 = $this->wrap_build_command_with_env_export("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->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("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->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("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}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3332,7 +3459,7 @@ private function generate_build_env_variables() $this->analyzeBuildTimeVariables($variables); } - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; } else { @@ -3445,7 +3572,7 @@ private function generate_secrets_hash($variables) } else { $secrets_string = $variables ->map(function ($env) { - return "{$env->key}={$env->real_value}"; + return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"; }) ->sort() ->implode('|'); @@ -3470,8 +3597,8 @@ protected function findFromInstructionLines($dockerfile): array private function add_build_env_variables_to_dockerfile() { - 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 + if ($this->dockerSecretsSupported) { + // We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag return; } @@ -3511,7 +3638,7 @@ private function add_build_env_variables_to_dockerfile() if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->real_value}"); + $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); } } // Add Coolify variables as ARGs @@ -3533,7 +3660,7 @@ private function add_build_env_variables_to_dockerfile() if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->real_value}"); + $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); } } // Add Coolify variables as ARGs @@ -3569,7 +3696,7 @@ private function add_build_env_variables_to_dockerfile() } } $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->real_value]; + return [$env->key => $env->getResolvedValueWithServer($this->mainServer)]; }); $secrets_hash = $this->generate_secrets_hash($envs_mapped); $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); @@ -3819,7 +3946,7 @@ private function modify_dockerfiles_for_compose($composeFile) $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)) { + if ($this->dockerSecretsSupported && ! 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."); @@ -3869,7 +3996,7 @@ private function add_build_secrets_to_compose($composeFile) $composeFile['services'] = $services; $existingSecrets = data_get($composeFile, 'secrets', []); - if ($existingSecrets instanceof \Illuminate\Support\Collection) { + if ($existingSecrets instanceof Collection) { $existingSecrets = $existingSecrets->toArray(); } $composeFile['secrets'] = array_replace($existingSecrets, $secrets); @@ -3879,6 +4006,81 @@ private function add_build_secrets_to_compose($composeFile) return $composeFile; } + private function validatePathField(string $value, string $fieldName): string + { + if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); + } + if (str_contains($value, '..')) { + throw new \RuntimeException("Invalid {$fieldName}: path traversal detected."); + } + + return $value; + } + + private function validateShellSafeCommand(string $value, string $fieldName): string + { + if (! preg_match(ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters."); + } + + return $value; + } + + private function validateContainerName(string $value): string + { + if (! preg_match(ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) { + throw new \RuntimeException('Invalid container name: contains forbidden characters.'); + } + + return $value; + } + + /** + * Resolve which container to execute a deployment command in. + * + * For single-container apps, returns the sole container. + * For multi-container apps, matches by the user-specified container name. + * If no container name is specified for multi-container apps, logs available containers and returns null. + */ + private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array + { + if ($containers->count() === 0) { + return null; + } + + if ($containers->count() === 1) { + return $containers->first(); + } + + // Multi-container: require a container name to be specified + if (empty($specifiedContainerName)) { + $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', '); + $this->application_deployment_queue->addLogEntry( + "{$commandType} command: Multiple containers found but no container name specified. Available: {$available}" + ); + + return null; + } + + // Multi-container: match by specified name prefix + $prefix = $specifiedContainerName.'-'.$this->application->uuid; + foreach ($containers as $container) { + $containerName = data_get($container, 'Names'); + if (str_starts_with($containerName, $prefix)) { + return $container; + } + } + + // No match found — log available containers to help the user debug + $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', '); + $this->application_deployment_queue->addLogEntry( + "{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}" + ); + + return null; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { @@ -3886,26 +4088,39 @@ private function run_pre_deployment_command() } $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($containers->count() == 0) { + $this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.'); + return; } $this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).'); - foreach ($containers as $container) { - $containerName = data_get($container, 'Names'); - if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) { - $cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; - $this->execute_remote_command( - [ - 'command' => $exec, - 'hidden' => true, - ], - ); - - return; - } + $container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment'); + if ($container === null) { + throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?'); } - throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?'); + + $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } + // Security: pre_deployment_command is intentionally treated as arbitrary shell input. + // Users (team members with deployment access) need full shell flexibility to run commands + // like "php artisan migrate", "npm run build", etc. inside their own application containers. + // The trust boundary is at the application/team ownership level — only authenticated team + // members can set these commands, and execution is scoped to the application's own container. + // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not + // restrict the command itself. Container names are validated separately via validateContainerName(). + // Newlines are normalized to spaces to prevent injection via SSH heredoc transport + // (matches the pattern used for health_check_command at line ~2824). + $preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command); + $cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'"; + $exec = "docker exec {$containerName} {$cmd}"; + $this->execute_remote_command( + [ + 'command' => $exec, + 'hidden' => true, + ], + ); } private function run_post_deployment_command() @@ -3917,31 +4132,42 @@ private function run_post_deployment_command() $this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).'); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); - foreach ($containers as $container) { - $containerName = data_get($container, 'Names'); - if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) { - $cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; - try { - $this->execute_remote_command( - [ - 'command' => $exec, - 'hidden' => true, - 'save' => 'post-deployment-command-output', - ], - ); - } catch (Exception $e) { - $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output'); - if ($post_deployment_command_output) { - $this->application_deployment_queue->addLogEntry('Post-deployment command failed.'); - $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr'); - } - } + if ($containers->count() == 0) { + $this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.'); - return; + return; + } + + $container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment'); + if ($container === null) { + throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?'); + } + + $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } + // Security: post_deployment_command is intentionally treated as arbitrary shell input. + // See the equivalent comment in run_pre_deployment_command() for the full security rationale. + // Newlines are normalized to spaces to prevent injection via SSH heredoc transport. + $postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command); + $cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'"; + $exec = "docker exec {$containerName} {$cmd}"; + try { + $this->execute_remote_command( + [ + 'command' => $exec, + 'hidden' => true, + 'save' => 'post-deployment-command-output', + ], + ); + } catch (Exception $e) { + $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output'); + if ($post_deployment_command_output) { + $this->application_deployment_queue->addLogEntry('Post-deployment command failed.'); + $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr'); } } - throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } /** diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 92ec4cbd4..91869eb12 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -6,12 +6,13 @@ use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class CheckTraefikVersionForServerJob implements ShouldQueue +class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index a513f280e..ac94aa23f 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -5,12 +5,13 @@ use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class CheckTraefikVersionJob implements ShouldQueue +class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/CleanupInstanceStuffsJob.php b/app/Jobs/CleanupInstanceStuffsJob.php index 011c58639..e37a39c3d 100644 --- a/app/Jobs/CleanupInstanceStuffsJob.php +++ b/app/Jobs/CleanupInstanceStuffsJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Models\ScheduledDatabaseBackup; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Bus\Queueable; @@ -12,6 +13,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue @@ -32,6 +34,7 @@ public function handle(): void try { $this->cleanupInvitationLink(); $this->cleanupExpiredEmailChangeRequests(); + $this->enforceBackupRetention(); } catch (\Throwable $e) { Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); } @@ -55,4 +58,25 @@ private function cleanupExpiredEmailChangeRequests() 'email_change_code_expires_at' => null, ]); } + + private function enforceBackupRetention(): void + { + if (! Cache::add('backup-retention-enforcement', true, 1800)) { + return; + } + + try { + $backups = ScheduledDatabaseBackup::where('enabled', true)->get(); + foreach ($backups as $backup) { + try { + removeOldBackups($backup); + } catch (\Throwable $e) { + Log::warning('Failed to enforce retention for backup '.$backup->id.': '.$e->getMessage()); + } + } + } catch (\Throwable $e) { + Log::error('Failed to enforce backup retention: '.$e->getMessage()); + Cache::forget('backup-retention-enforcement'); + } + } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..207191cbd 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -22,6 +22,7 @@ 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\Facades\Log; use Illuminate\Support\Str; @@ -80,6 +81,13 @@ public function __construct(public ScheduledDatabaseBackup $backup) $this->timeout = $backup->timeout ?? 3600; } + public function middleware(): array + { + $expireAfter = ($this->backup->timeout ?? 3600) + 300; + + return [(new WithoutOverlapping('database-backup-'.$this->backup->id))->expireAfter($expireAfter)->dontRelease()]; + } + public function handle(): void { try { @@ -91,7 +99,7 @@ public function handle(): void return; } - if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) { + if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $this->database = data_get($this->backup, 'database'); $this->server = $this->database->service->server; $this->s3 = $this->backup->s3; @@ -107,13 +115,21 @@ public function handle(): void throw new \Exception('Database not found?!'); } + $this->markStaleExecutionsAsFailed(); + BackupCreated::dispatch($this->team->id); $status = str(data_get($this->database, 'status')); if (! $status->startsWith('running') && $this->database->id !== 0) { + Log::info('DatabaseBackupJob skipped: database not running', [ + 'backup_id' => $this->backup->id, + 'database_id' => $this->database->id, + 'status' => (string) $status, + ]); + return; } - if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) { + if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $databaseType = $this->database->databaseType(); $serviceUuid = $this->database->service->uuid; $serviceName = str($this->database->service->name)->slug(); @@ -235,7 +251,7 @@ public function handle(): void } } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Continue without env vars - will be handled in backup_standalone_mongodb method } } @@ -382,7 +398,7 @@ public function handle(): void } else { throw new \Exception('Local backup file is empty or was not created'); } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Local backup failed if ($this->backup_log) { $this->backup_log->update([ @@ -393,7 +409,15 @@ public function handle(): void 's3_uploaded' => null, ]); } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + try { + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + } catch (Throwable $notifyException) { + Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [ + 'backup_id' => $this->backup->uuid, + 'database' => $database, + 'error' => $notifyException->getMessage(), + ]); + } continue; } @@ -409,7 +433,7 @@ public function handle(): void deleteBackupsLocally($this->backup_location, $this->server); $localStorageDeleted = true; } - } catch (\Throwable $e) { + } catch (Throwable $e) { // S3 upload failed but local backup succeeded $s3UploadError = $e->getMessage(); } @@ -433,18 +457,27 @@ public function handle(): void 'local_storage_deleted' => $localStorageDeleted, ]); - // Send appropriate notification - if ($s3UploadError) { - $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); - } else { - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Send appropriate notification (wrapped in try-catch so notification + // failures never affect backup status — see GitHub issue #9088) + try { + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); + } else { + $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + } + } catch (Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [ + 'backup_id' => $this->backup->uuid, + 'database' => $database, + 'error' => $e->getMessage(), + ]); } } } if ($this->backup_log && $this->backup_log->status === 'success') { removeOldBackups($this->backup); } - } catch (\Throwable $e) { + } catch (Throwable $e) { throw $e; } finally { if ($this->team) { @@ -466,19 +499,23 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi // For service-based MongoDB, try to build URL from environment variables if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) { // Use container name instead of server IP for service-based MongoDB - $url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017"; + // URL-encode credentials to prevent URI injection + $encodedUser = rawurlencode($this->mongo_root_username); + $encodedPass = rawurlencode($this->mongo_root_password); + $url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->container_name}:27017"; } else { // If no environment variables are available, throw an exception throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.'); } } - \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); + Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); + $escapedUrl = escapeshellarg($url); if ($databaseWithCollections === 'all') { $commands[] = 'mkdir -p '.$this->backup_dir; if (str($this->database->image)->startsWith('mongo:4')) { - $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --gzip --archive > $this->backup_location"; } } else { if (str($databaseWithCollections)->contains(':')) { @@ -496,15 +533,23 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi if ($collectionsToExclude->count() === 0) { if (str($this->database->image)->startsWith('mongo:4')) { - $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --archive > $this->backup_location"; } } else { + // Validate and escape each collection name + $escapedCollections = $collectionsToExclude->map(function ($collection) { + $collection = trim($collection); + validateShellSafePath($collection, 'collection name'); + + return escapeshellarg($collection); + }); + if (str($this->database->image)->startsWith('mongo:4')) { - $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location"; } } } @@ -513,7 +558,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi if ($this->backup_output === '') { $this->backup_output = null; } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->add_to_error_output($e->getMessage()); throw $e; } @@ -525,15 +570,16 @@ private function backup_standalone_postgresql(string $database): void $commands[] = 'mkdir -p '.$this->backup_dir; $backupCommand = 'docker exec'; if ($this->postgres_password) { - $backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\""; + $backupCommand .= ' -e PGPASSWORD='.escapeshellarg($this->postgres_password); } + $escapedUsername = escapeshellarg($this->database->postgres_user); if ($this->backup->dump_all) { - $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location"; + $backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername | gzip > $this->backup_location"; } else { // Validate and escape database name to prevent command injection validateShellSafePath($database, 'database name'); $escapedDatabase = escapeshellarg($database); - $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location"; + $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username $escapedUsername $escapedDatabase > $this->backup_location"; } $commands[] = $backupCommand; @@ -542,7 +588,7 @@ private function backup_standalone_postgresql(string $database): void if ($this->backup_output === '') { $this->backup_output = null; } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->add_to_error_output($e->getMessage()); throw $e; } @@ -552,20 +598,21 @@ private function backup_standalone_mysql(string $database): void { try { $commands[] = 'mkdir -p '.$this->backup_dir; + $escapedPassword = escapeshellarg($this->database->mysql_root_password); if ($this->backup->dump_all) { - $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location"; + $commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location"; } else { // Validate and escape database name to prevent command injection validateShellSafePath($database, 'database name'); $escapedDatabase = escapeshellarg($database); - $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; + $commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword $escapedDatabase > $this->backup_location"; } $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->add_to_error_output($e->getMessage()); throw $e; } @@ -575,20 +622,21 @@ private function backup_standalone_mariadb(string $database): void { try { $commands[] = 'mkdir -p '.$this->backup_dir; + $escapedPassword = escapeshellarg($this->database->mariadb_root_password); if ($this->backup->dump_all) { - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location"; + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location"; } else { // Validate and escape database name to prevent command injection validateShellSafePath($database, 'database name'); $escapedDatabase = escapeshellarg($database); - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword $escapedDatabase > $this->backup_location"; } $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->add_to_error_output($e->getMessage()); throw $e; } @@ -619,21 +667,28 @@ private function calculate_size() private function upload_to_s3(): void { + if (is_null($this->s3)) { + $this->backup->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + + throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); + } + try { - if (is_null($this->s3)) { - return; - } $key = $this->s3->key; $secret = $this->s3->secret; // $region = $this->s3->region; $bucket = $this->s3->bucket; $endpoint = $this->s3->endpoint; $this->s3->testConnection(shouldSave: true); - if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) { + if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $network = $this->database->service->destination->network; } else { $network = $this->database->destination->network; } + $safeNetwork = escapeshellarg($network); $fullImageName = $this->getFullImageName(); @@ -645,13 +700,13 @@ private function upload_to_s3(): void if (isDev()) { if ($this->database->name === 'coolify-db') { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } else { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } } else { - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } // Escape S3 credentials to prevent command injection @@ -664,7 +719,7 @@ private function upload_to_s3(): void instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $this->s3_uploaded = true; - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->s3_uploaded = false; $this->add_to_error_output($e->getMessage()); throw $e; @@ -682,6 +737,31 @@ private function getFullImageName(): string return "{$helperImage}:{$latestVersion}"; } + private function markStaleExecutionsAsFailed(): void + { + try { + $timeoutSeconds = ($this->backup->timeout ?? 3600) * 2; + + $staleExecutions = $this->backup->executions() + ->where('status', 'running') + ->where('created_at', '<', now()->subSeconds($timeoutSeconds)) + ->get(); + + foreach ($staleExecutions as $execution) { + $execution->update([ + 'status' => 'failed', + 'message' => 'Marked as failed - backup execution exceeded maximum allowed time', + 'finished_at' => now(), + ]); + } + } catch (Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to clean up stale backup executions', [ + 'backup_id' => $this->backup->uuid, + 'error' => $e->getMessage(), + ]); + } + } + public function failed(?Throwable $exception): void { Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [ @@ -698,20 +778,32 @@ public function failed(?Throwable $exception): void $log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first(); if ($log) { - $log->update([ - 'status' => 'failed', - 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), - 'size' => 0, - 'filename' => null, - 'finished_at' => Carbon::now(), - ]); + // Don't overwrite a successful backup status — a post-backup error + // (e.g. notification failure) should not retroactively mark the backup + // as failed (see GitHub issue #9088) + if ($log->status !== 'success') { + $log->update([ + 'status' => 'failed', + 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), + 'size' => 0, + 'filename' => null, + 'finished_at' => Carbon::now(), + ]); + } } - // Notify team about permanent failure - if ($this->team) { + // Notify team about permanent failure (only if backup didn't already succeed) + if ($this->team && $log?->status !== 'success') { $databaseName = $log?->database_name ?? 'unknown'; $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; - $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + try { + $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + } catch (Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [ + 'backup_id' => $this->backup->uuid, + 'error' => $e->getMessage(), + ]); + } } } } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index f3f3a2ae4..16f3d88ad 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -39,19 +39,27 @@ public function __construct( public bool $manualCleanup = false, public bool $deleteUnusedVolumes = false, public bool $deleteUnusedNetworks = false - ) {} + ) { + $this->onQueue('high'); + } public function handle(): void { try { - if (! $this->server->isFunctional()) { - return; - } - $this->execution_log = DockerCleanupExecution::create([ 'server_id' => $this->server->id, ]); + if (! $this->server->isFunctional()) { + $this->execution_log->update([ + 'status' => 'failed', + 'message' => 'Server is not functional (unreachable, unusable, or disabled)', + 'finished_at' => Carbon::now()->toImmutable(), + ]); + + return; + } + $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { @@ -91,6 +99,8 @@ public function handle(): void $this->server->team?->notify(new DockerCleanupSuccess($this->server, $message)); event(new DockerCleanupDone($this->execution_log)); + + return; } if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index c02a7e3c5..b1a12ae2a 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -24,6 +24,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Laravel\Horizon\Contracts\Silenced; class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced @@ -130,7 +131,14 @@ public function handle() $this->containers = collect(data_get($data, 'containers')); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + + // Only dispatch storage check when disk percentage actually changes + $storageCacheKey = 'storage-check:'.$this->server->id; + $lastPercentage = Cache::get($storageCacheKey); + if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { + Cache::put($storageCacheKey, $filesystemUsageRoot, 600); + ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + } if ($this->containers->isEmpty()) { return; @@ -207,6 +215,9 @@ public function handle() $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); + if (empty(trim((string) $subId))) { + continue; + } if ($subType === 'application') { $this->foundServiceApplicationIds->push($subId); // Store container status for aggregation @@ -296,6 +307,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } continue; @@ -310,6 +323,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } } } @@ -324,6 +339,10 @@ private function aggregateServiceContainerStatuses() // Parse key: serviceId:subType:subId [$serviceId, $subType, $subId] = explode(':', $key); + if (empty($subId)) { + continue; + } + $service = $this->services->where('id', $serviceId)->first(); if (! $service) { continue; @@ -332,9 +351,9 @@ private function aggregateServiceContainerStatuses() // Get the service sub-resource (ServiceApplication or ServiceDatabase) $subResource = null; if ($subType === 'application') { - $subResource = $service->applications()->where('id', $subId)->first(); + $subResource = $service->applications->where('id', $subId)->first(); } elseif ($subType === 'database') { - $subResource = $service->databases()->where('id', $subId)->first(); + $subResource = $service->databases->where('id', $subId)->first(); } if (! $subResource) { @@ -356,6 +375,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } continue; @@ -371,6 +392,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } } } @@ -384,6 +407,8 @@ private function updateApplicationStatus(string $applicationId, string $containe if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); + } else { + $application->update(['last_online_at' => now()]); } } @@ -398,6 +423,8 @@ private function updateApplicationPreviewStatus(string $applicationId, string $p if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); + } else { + $application->update(['last_online_at' => now()]); } } @@ -473,8 +500,13 @@ private function updateProxyStatus() } catch (\Throwable $e) { } } else { - // Connect proxy to networks asynchronously to avoid blocking the status update - ConnectProxyToNetworksJob::dispatch($this->server); + // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. + // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. + $proxyCacheKey = 'connect-proxy:'.$this->server->id; + if (! Cache::has($proxyCacheKey)) { + Cache::put($proxyCacheKey, true, 600); + ConnectProxyToNetworksJob::dispatch($this->server); + } } } } @@ -488,6 +520,8 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta if ($database->status !== $containerStatus) { $database->status = $containerStatus; $database->save(); + } else { + $database->update(['last_online_at' => now()]); } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { @@ -525,8 +559,12 @@ private function updateNotFoundDatabaseStatus() $database = $this->databases->where('uuid', $databaseUuid)->first(); if ($database) { if (! str($database->status)->startsWith('exited')) { - $database->status = 'exited'; - $database->save(); + $database->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } if ($database->is_public) { StopDatabaseProxy::dispatch($database); @@ -535,31 +573,6 @@ private function updateNotFoundDatabaseStatus() }); } - private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus) - { - $service = $this->services->where('id', $serviceId)->first(); - if (! $service) { - return; - } - if ($subType === 'application') { - $application = $service->applications()->where('id', $subId)->first(); - if ($application) { - if ($application->status !== $containerStatus) { - $application->status = $containerStatus; - $application->save(); - } - } - } elseif ($subType === 'database') { - $database = $service->databases()->where('id', $subId)->first(); - if ($database) { - if ($database->status !== $containerStatus) { - $database->status = $containerStatus; - $database->save(); - } - } - } - } - private function updateNotFoundServiceStatus() { $notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds); diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index c0284e1ee..6f49cf30b 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -7,13 +7,14 @@ use App\Models\Team; use App\Notifications\SslExpirationNotification; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class RegenerateSslCertJob implements ShouldQueue +class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 75ff883c2..71829ea41 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -6,7 +6,6 @@ use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; -use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -15,7 +14,9 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Redis; class ScheduledJobManager implements ShouldQueue { @@ -27,6 +28,10 @@ class ScheduledJobManager implements ShouldQueue */ private ?Carbon $executionTime = null; + private int $dispatchedCount = 0; + + private int $skippedCount = 0; + /** * Create a new job instance. */ @@ -50,6 +55,11 @@ private function determineQueue(): string */ public function middleware(): array { + // Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it. + // Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases. + // @see https://github.com/coollabsio/coolify/issues/8327 + self::clearStaleLockIfPresent(); + return [ (new WithoutOverlapping('scheduled-job-manager')) ->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks @@ -57,10 +67,44 @@ public function middleware(): array ]; } + /** + * Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1). + * + * This provides continuous self-healing since it runs every time the job is dispatched. + * Stale locks permanently block all scheduled job executions with no user-visible error. + */ + private static function clearStaleLockIfPresent(): void + { + try { + $cachePrefix = config('cache.prefix', ''); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager'; + + $ttl = Redis::connection('default')->ttl($lockKey); + + if ($ttl === -1) { + Redis::connection('default')->del($lockKey); + Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [ + 'lock_key' => $lockKey, + ]); + } + } catch (\Throwable $e) { + // Never let lock cleanup failure prevent the job from running + Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [ + 'error' => $e->getMessage(), + ]); + } + } + public function handle(): void { // Freeze the execution time at the start of the job $this->executionTime = Carbon::now(); + $this->dispatchedCount = 0; + $this->skippedCount = 0; + + Log::channel('scheduled')->info('ScheduledJobManager started', [ + 'execution_time' => $this->executionTime->toIso8601String(), + ]); // Process backups - don't let failures stop task processing try { @@ -91,6 +135,20 @@ public function handle(): void 'trace' => $e->getTraceAsString(), ]); } + + Log::channel('scheduled')->info('ScheduledJobManager completed', [ + 'execution_time' => $this->executionTime->toIso8601String(), + 'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()), + 'dispatched' => $this->dispatchedCount, + 'skipped' => $this->skippedCount, + ]); + + // Write heartbeat so the UI can detect when the scheduler has stopped + try { + Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300); + } catch (\Throwable) { + // Non-critical; don't let heartbeat failure affect the job + } } private function processScheduledBackups(): void @@ -101,12 +159,20 @@ private function processScheduledBackups(): void foreach ($backups as $backup) { try { - // Apply the same filtering logic as the original - if (! $this->shouldProcessBackup($backup)) { + $server = $backup->server(); + $skipReason = $this->getBackupSkipReason($backup, $server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('backup', $skipReason, [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + ]); + continue; } - $server = $backup->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -118,8 +184,16 @@ private function processScheduledBackups(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) { DatabaseBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Backup dispatched', [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + 'server_id' => $server->id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing backup', [ @@ -138,11 +212,21 @@ private function processScheduledTasks(): void foreach ($tasks as $task) { try { - if (! $this->shouldProcessTask($task)) { + $server = $task->server(); + + // Phase 1: Critical checks (always — cheap, handles orphans and infra issues) + $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); + if ($criticalSkip !== null) { + $this->skippedCount++; + $this->logSkip('task', $criticalSkip, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server?->team_id, + ]); + continue; } - $server = $task->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -154,9 +238,31 @@ private function processScheduledTasks(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { - ScheduledTaskJob::dispatch($task); + if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) { + continue; } + + // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources) + $runtimeSkip = $this->getTaskRuntimeSkipReason($task); + if ($runtimeSkip !== null) { + $this->skippedCount++; + $this->logSkip('task', $runtimeSkip, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + ]); + + continue; + } + + ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing task', [ 'task_id' => $task->id, @@ -166,79 +272,67 @@ private function processScheduledTasks(): void } } - private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool + private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string { if (blank(data_get($backup, 'database'))) { $backup->delete(); - return false; + return 'database_deleted'; } - $server = $backup->server(); if (blank($server)) { $backup->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } - return true; + return null; } - private function shouldProcessTask(ScheduledTask $task): bool + private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string { - $service = $task->service; - $application = $task->application; - - $server = $task->server(); if (blank($server)) { $task->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } - if (! $service && ! $application) { + if (! $task->service && ! $task->application) { $task->delete(); - return false; + return 'resource_deleted'; } - if ($application && str($application->status)->contains('running') === false) { - return false; - } - - if ($service && str($service->status)->contains('running') === false) { - return false; - } - - return true; + return null; } - private function shouldRunNow(string $frequency, string $timezone): bool + private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string { - $cron = new CronExpression($frequency); + if ($task->application && str($task->application->status)->contains('running') === false) { + return 'application_not_running'; + } - // Use the frozen execution time, not the current time - // Fallback to current time if execution time is not set (shouldn't happen) - $baseTime = $this->executionTime ?? Carbon::now(); - $executionTime = $baseTime->copy()->setTimezone($timezone); + if ($task->service && str($task->service->status)->contains('running') === false) { + return 'service_not_running'; + } - return $cron->isDue($executionTime); + return null; } private function processDockerCleanups(): void @@ -248,7 +342,15 @@ private function processDockerCleanups(): void foreach ($servers as $server) { try { - if (! $this->shouldProcessDockerCleanup($server)) { + $skipReason = $this->getDockerCleanupSkipReason($server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); + continue; } @@ -263,13 +365,19 @@ private function processDockerCleanups(): void } // Use the frozen execution time for consistent evaluation - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) { DockerCleanupJob::dispatch( $server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks ); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Docker cleanup dispatched', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ @@ -296,19 +404,28 @@ private function getServersForCleanup(): Collection return $query->get(); } - private function shouldProcessDockerCleanup(Server $server): bool + private function getDockerCleanupSkipReason(Server $server): ?string { if (! $server->isFunctional()) { - return false; + return 'server_not_functional'; } // 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 'subscription_unpaid'; } } - return true; + return null; + } + + private function logSkip(string $type, string $reason, array $context = []): void + { + Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([ + 'type' => $type, + 'skip_reason' => $reason, + 'execution_time' => $this->executionTime?->toIso8601String(), + ], $context)); } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index b21bc11a1..49b9b9702 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -14,13 +14,14 @@ use App\Notifications\ScheduledTask\TaskSuccess; use Carbon\Carbon; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class ScheduledTaskJob implements ShouldQueue +class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index fcd87a9dd..f869fd602 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -4,16 +4,27 @@ use App\Notifications\Dto\SlackMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; -class SendMessageToSlackJob implements ShouldQueue +class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * The number of times the job may be attempted. + */ + public $tries = 5; + + /** + * The number of seconds to wait before retrying the job. + */ + public $backoff = 10; + public function __construct( private SlackMessage $message, private string $webhookUrl diff --git a/app/Jobs/SendMessageToTelegramJob.php b/app/Jobs/SendMessageToTelegramJob.php index 6b0a64ae3..6b04d2191 100644 --- a/app/Jobs/SendMessageToTelegramJob.php +++ b/app/Jobs/SendMessageToTelegramJob.php @@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue */ public $tries = 5; + /** + * The number of seconds to wait before retrying the job. + */ + public $backoff = 10; + /** * The maximum number of unhandled exceptions to allow before failing. */ diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index 607fda3fe..9d2a94606 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -9,6 +9,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue { @@ -40,6 +42,20 @@ public function __construct( */ public function handle(): void { + $validator = Validator::make( + ['webhook_url' => $this->webhookUrl], + ['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]] + ); + + if ($validator->fails()) { + Log::warning('SendWebhookJob: blocked unsafe webhook URL', [ + 'url' => $this->webhookUrl, + 'errors' => $validator->errors()->all(), + ]); + + return; + } + if (isDev()) { ray('Sending webhook notification', [ 'url' => $this->webhookUrl, diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 2ac92e72d..10faa7e9b 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -15,6 +15,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Queue\TimeoutExceededException; +use Illuminate\Support\Facades\Log; class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,6 +35,20 @@ public function middleware(): array public function __construct(public Server $server) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof TimeoutExceededException) { + Log::warning('ServerCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + $this->server->increment('unreachable_count'); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 9dbce4bfe..7ce316dcd 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -2,8 +2,10 @@ namespace App\Jobs; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ConfigurationRepository; +use App\Services\HetznerService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -11,7 +13,9 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Process; class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue { @@ -19,7 +23,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue public $tries = 1; - public $timeout = 30; + public $timeout = 15; public function __construct( public Server $server, @@ -28,7 +32,7 @@ public function __construct( public function middleware(): array { - return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(45)->dontRelease()]; + return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(25)->dontRelease()]; } private function disableSshMux(): void @@ -72,6 +76,7 @@ public function handle() 'is_reachable' => false, 'is_usable' => false, ]); + $this->server->increment('unreachable_count'); Log::warning('ServerConnectionCheck: Server not reachable', [ 'server_id' => $this->server->id, @@ -90,6 +95,10 @@ public function handle() 'is_usable' => $isUsable, ]); + if ($this->server->unreachable_count > 0) { + $this->server->update(['unreachable_count' => 0]); + } + } catch (\Throwable $e) { Log::error('ServerConnectionCheckJob failed', [ @@ -100,23 +109,37 @@ public function handle() 'is_reachable' => false, 'is_usable' => false, ]); + $this->server->increment('unreachable_count'); - throw $e; + return; + } + } + + public function failed(?\Throwable $exception): void + { + if ($exception instanceof TimeoutExceededException) { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + $this->server->increment('unreachable_count'); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); } } private function checkHetznerStatus(): void { + $status = null; + try { - $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService = new HetznerService($this->server->cloudProviderToken->token); $serverData = $hetznerService->getServer($this->server->hetzner_server_id); $status = $serverData['status'] ?? null; - } catch (\Throwable $e) { - Log::debug('ServerConnectionCheck: Hetzner status check failed', [ - 'server_id' => $this->server->id, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + // Silently ignore — server may have been deleted from Hetzner. } if ($this->server->hetzner_server_status !== $status) { $this->server->update(['hetzner_server_status' => $status]); @@ -132,15 +155,18 @@ private function checkHetznerStatus(): void 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 - ); + // Single SSH attempt without SshRetryHandler — retries waste time for connectivity checks. + // Backoff is managed at the dispatch level via unreachable_count. + $commands = ['ls -la /']; + if ($this->server->isNonRoot()) { + $commands = parseCommandsByLineForSudo(collect($commands), $this->server); + } + $commandString = implode("\n", $commands); - return $output !== null; + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $commandString, true); + $process = Process::timeout(10)->run($sshCommand); + + return $process->exitCode() === 0; } catch (\Throwable $e) { Log::debug('ServerConnectionCheck: Connection check failed', [ 'server_id' => $this->server->id, diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php index aa82c6dad..06e94fc93 100644 --- a/app/Jobs/ServerLimitCheckJob.php +++ b/app/Jobs/ServerLimitCheckJob.php @@ -38,7 +38,7 @@ public function handle() $server->forceDisableServer(); $this->team->notify(new ForceDisabled($server)); }); - } elseif ($number_of_servers_to_disable === 0) { + } elseif ($number_of_servers_to_disable <= 0) { $servers->each(function ($server) { if ($server->isForceDisabled()) { $server->forceEnableServer(); diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index a4619354d..9532282cc 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -5,8 +5,8 @@ use App\Models\InstanceSettings; use App\Models\Server; use App\Models\Team; -use Cron\CronExpression; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -15,7 +15,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; -class ServerManagerJob implements ShouldQueue +class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -64,11 +64,11 @@ public function handle(): void private function getServers(): Collection { - $allServers = Server::where('ip', '!=', '1.2.3.4'); + $allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4'); if (isCloud()) { $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers; + $own = Team::find(0)->servers()->with('settings')->get(); return $servers->merge($own); } else { @@ -79,9 +79,16 @@ private function getServers(): Collection private function dispatchConnectionChecks(Collection $servers): void { - if ($this->shouldRunNow($this->checkFrequency)) { + if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) { $servers->each(function (Server $server) { try { + // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity + if ($server->isSentinelEnabled() && $server->isSentinelLive()) { + return; + } + if ($this->shouldSkipDueToBackoff($server)) { + return; + } ServerConnectionCheckJob::dispatch($server); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ @@ -124,19 +131,19 @@ private function processServerTasks(Server $server): void if ($sentinelOutOfSync) { // Dispatch ServerCheckJob if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { - ServerCheckJob::dispatch($server); + if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) { + if (! $this->shouldSkipDueToBackoff($server)) { + ServerCheckJob::dispatch($server); + } } } $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + $shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime); // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); + CheckAndStartSentinelJob::dispatch($server); } // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) @@ -146,7 +153,7 @@ private function processServerTasks(Server $server): void if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); + $shouldRunStorageCheck = shouldRunCronNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}", $this->executionTime); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); @@ -154,27 +161,48 @@ private function processServerTasks(Server $server): void } // Dispatch ServerPatchCheckJob if due (weekly) - $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); + $shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime); if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight ServerPatchCheckJob::dispatch($server); } - // Sentinel update checks (hourly) - check for updates to Sentinel version - // No timezone needed for hourly - runs at top of every hour - if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) { - CheckAndStartSentinelJob::dispatch($server); - } + // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. + // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } - private function shouldRunNow(string $frequency, ?string $timezone = null): bool + /** + * Determine the backoff cycle interval based on how many consecutive times a server has been unreachable. + * Higher counts → less frequent checks (based on 5-min cloud cycle): + * 0-2: every cycle, 3-5: ~15 min, 6-11: ~30 min, 12+: ~60 min + */ + private function getBackoffCycleInterval(int $unreachableCount): int { - $cron = new CronExpression($frequency); + return match (true) { + $unreachableCount <= 2 => 1, + $unreachableCount <= 5 => 3, + $unreachableCount <= 11 => 6, + default => 12, + }; + } - // Use the frozen execution time, not the current time - $baseTime = $this->executionTime ?? Carbon::now(); - $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); + /** + * Check if a server should be skipped this cycle due to unreachable backoff. + * Uses server ID hash to distribute checks across cycles (avoid thundering herd). + */ + private function shouldSkipDueToBackoff(Server $server): bool + { + $unreachableCount = $server->unreachable_count ?? 0; + $interval = $this->getBackoffCycleInterval($unreachableCount); - return $cron->isDue($executionTime); + if ($interval <= 1) { + return false; + } + + $cyclePeriodMinutes = isCloud() ? 5 : 1; + $cycleIndex = intdiv($this->executionTime->minute, $cyclePeriodMinutes); + $serverHash = abs(crc32((string) $server->id)); + + return ($cycleIndex + $serverHash) % $interval !== 0; } } diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 9d45491c6..51426d880 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Horizon\Contracts\Silenced; @@ -28,6 +29,19 @@ public function backoff(): int public function __construct(public Server $server, public int|string|null $percentage = null) {} + public function failed(?\Throwable $exception): void + { + if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { + Log::warning('ServerStorageCheckJob timed out', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Delete the queue job so it doesn't appear in Horizon's failed list. + $this->job?->delete(); + } + } + public function handle() { try { diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index aebceaa6d..3485ffe32 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -2,13 +2,15 @@ namespace App\Jobs; +use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Subscription; use App\Models\Team; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Str; -class StripeProcessJob implements ShouldQueue +class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue { use Queueable; @@ -71,25 +73,15 @@ public function handle(): void // 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(); - if ($subscription) { - // send_internal_notification('Old subscription activated for team: '.$teamId); - $subscription->update([ + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => true, 'stripe_past_due' => false, - ]); - } else { - // send_internal_notification('New subscription for team: '.$teamId); - Subscription::create([ - 'team_id' => $teamId, - 'stripe_subscription_id' => $subscriptionId, - 'stripe_customer_id' => $customerId, - 'stripe_invoice_paid' => true, - 'stripe_past_due' => false, - ]); - } + ] + ); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -225,18 +217,15 @@ public function handle(): void // 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(); - if ($subscription) { - // send_internal_notification("Subscription already exists for team: {$teamId}"); - throw new \RuntimeException("Subscription already exists for team: {$teamId}"); - } else { - Subscription::create([ - 'team_id' => $teamId, + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } + ] + ); + break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); $userId = data_get($data, 'metadata.user_id'); @@ -251,34 +240,33 @@ public function handle(): void $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { if ($status === 'incomplete_expired') { - // send_internal_notification('Subscription incomplete expired'); throw new \RuntimeException('Subscription incomplete expired'); } - if ($teamId) { - $subscription = Subscription::create([ - 'team_id' => $teamId, + if (! $teamId) { + throw new \RuntimeException('No subscription and team id found'); + } + $subscription = Subscription::firstOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } else { - // send_internal_notification('No subscription and team id found'); - throw new \RuntimeException('No subscription and team id found'); - } + ] + ); } $cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end'); $feedback = data_get($data, 'cancellation_details.feedback'); $comment = data_get($data, 'cancellation_details.comment'); $lookup_key = data_get($data, 'items.data.0.price.lookup_key'); if (str($lookup_key)->contains('dynamic')) { - $quantity = data_get($data, 'items.data.0.quantity', 2); + $quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT); $team = data_get($subscription, 'team'); if ($team) { $team->update([ 'custom_server_limit' => $quantity, ]); + ServerLimitCheckJob::dispatch($team); } - ServerLimitCheckJob::dispatch($team); } $subscription->update([ 'stripe_feedback' => $feedback, diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php index 9eb946e4d..0e221756d 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -4,12 +4,13 @@ use App\Models\Subscription; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class SyncStripeSubscriptionsJob implements ShouldQueue +class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -22,7 +23,7 @@ public function __construct(public bool $fix = false) $this->onQueue('high'); } - public function handle(): array + public function handle(?\Closure $onProgress = null): array { if (! isCloud() || ! isStripe()) { return ['error' => 'Not running on Cloud or Stripe not configured']; @@ -33,48 +34,73 @@ public function handle(): array ->get(); $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + + // Bulk fetch all valid subscription IDs from Stripe (active + past_due) + $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); + + // Find DB subscriptions not in the valid set + $staleSubscriptions = $subscriptions->filter( + fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds) + ); + + // For each stale subscription, get the exact Stripe status and check for resubscriptions $discrepancies = []; + $resubscribed = []; $errors = []; - foreach ($subscriptions as $subscription) { + foreach ($staleSubscriptions as $subscription) { try { $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id ); + $stripeStatus = $stripeSubscription->status; - // Check if Stripe says cancelled but we think it's active - if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) { - $discrepancies[] = [ - 'subscription_id' => $subscription->id, - 'team_id' => $subscription->team_id, - 'stripe_subscription_id' => $subscription->stripe_subscription_id, - 'stripe_status' => $stripeSubscription->status, - ]; - - // Only fix if --fix flag is passed - if ($this->fix) { - $subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_past_due' => false, - ]); - - if ($stripeSubscription->status === 'canceled') { - $subscription->team?->subscriptionEnded(); - } - } - } - - // Small delay to avoid Stripe rate limits - usleep(100000); // 100ms + usleep(100000); // 100ms rate limit delay } catch (\Exception $e) { $errors[] = [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]; + + continue; + } + + // Check if this user resubscribed under a different customer/subscription + $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer); + if ($activeSub) { + $resubscribed[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'email' => $activeSub['email'], + 'old_stripe_subscription_id' => $subscription->stripe_subscription_id, + 'old_stripe_customer_id' => $stripeSubscription->customer, + 'new_stripe_subscription_id' => $activeSub['subscription_id'], + 'new_stripe_customer_id' => $activeSub['customer_id'], + 'new_status' => $activeSub['status'], + ]; + + continue; + } + + $discrepancies[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + 'stripe_status' => $stripeStatus, + ]; + + if ($this->fix) { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + + if ($stripeStatus === 'canceled') { + $subscription->team?->subscriptionEnded(); + } } } - // Only notify if discrepancies found and fixed if ($this->fix && count($discrepancies) > 0) { send_internal_notification( 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". @@ -85,8 +111,88 @@ public function handle(): array return [ 'total_checked' => $subscriptions->count(), 'discrepancies' => $discrepancies, + 'resubscribed' => $resubscribed, 'errors' => $errors, 'fixed' => $this->fix, ]; } + + /** + * Given a Stripe customer ID, get their email and search for other customers + * with the same email that have an active subscription. + * + * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null + */ + private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array + { + try { + $customer = $stripe->customers->retrieve($customerId); + $email = $customer->email; + + if (! $email) { + return null; + } + + usleep(100000); + + $customers = $stripe->customers->all([ + 'email' => $email, + 'limit' => 10, + ]); + + usleep(100000); + + foreach ($customers->data as $matchingCustomer) { + if ($matchingCustomer->id === $customerId) { + continue; + } + + $subs = $stripe->subscriptions->all([ + 'customer' => $matchingCustomer->id, + 'limit' => 10, + ]); + + usleep(100000); + + foreach ($subs->data as $sub) { + if (in_array($sub->status, ['active', 'past_due'])) { + return [ + 'email' => $email, + 'customer_id' => $matchingCustomer->id, + 'subscription_id' => $sub->id, + 'status' => $sub->status, + ]; + } + } + } + } catch (\Exception $e) { + // Silently skip — will fall through to normal discrepancy + } + + return null; + } + + /** + * Bulk fetch all active and past_due subscription IDs from Stripe. + * + * @return array + */ + private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array + { + $validIds = []; + $fetched = 0; + + foreach (['active', 'past_due'] as $status) { + foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { + $validIds[] = $sub->id; + $fetched++; + + if ($onProgress) { + $onProgress($fetched); + } + } + } + + return $validIds; + } } diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index b5e1929de..ee8cf2797 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -8,13 +8,14 @@ use App\Events\ServerValidated; use App\Models\Server; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class ValidateAndInstallServerJob implements ShouldQueue +class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -44,7 +45,8 @@ public function handle(): void // Validate connection ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if (! $uptime) { - $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError; $this->server->update([ 'validation_logs' => $errorMessage, 'is_validating' => false, @@ -178,6 +180,9 @@ public function handle(): void // Mark validation as complete $this->server->update(['is_validating' => false]); + // Auto-fetch server details now that validation passed + $this->server->gatherServerMetadata(); + // Refresh server to get latest state $this->server->refresh(); @@ -193,7 +198,7 @@ public function handle(): void ]); $this->server->update([ - 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(), + 'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'), 'is_validating' => false, ]); } diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index 58b6944a2..f7addacf1 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -4,12 +4,13 @@ use App\Models\Subscription; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class VerifyStripeSubscriptionStatusJob implements ShouldQueue +class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -81,12 +82,9 @@ public function handle(): void 'stripe_past_due' => false, ]); - // Trigger subscription ended logic if canceled - if ($stripeSubscription->status === 'canceled') { - $team = $this->subscription->team; - if ($team) { - $team->subscriptionEnded(); - } + $team = $this->subscription->team; + if ($team) { + $team->subscriptionEnded(); } break; diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 370ff1eaa..665d14ba0 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -2,7 +2,9 @@ namespace App\Livewire; +use App\Models\Server; use App\Models\User; +use Livewire\Attributes\Locked; use Livewire\Component; use Spatie\Activitylog\Models\Activity; @@ -10,6 +12,7 @@ class ActivityMonitor extends Component { public ?string $header = null; + #[Locked] public $activityId = null; public $eventToDispatch = 'activityFinished'; @@ -55,16 +58,49 @@ public function hydrateActivity() return; } - $this->activity = Activity::find($this->activityId); - } + $activity = Activity::find($this->activityId); - public function updatedActivityId($value) - { - if ($value) { - $this->hydrateActivity(); - $this->isPollingActive = true; - self::$eventDispatched = false; + if (! $activity) { + $this->activity = null; + + return; } + + $currentTeamId = currentTeam()?->id; + + // Check team_id stored directly in activity properties + $activityTeamId = data_get($activity, 'properties.team_id'); + if ($activityTeamId !== null) { + if ((int) $activityTeamId !== (int) $currentTeamId) { + $this->activity = null; + + return; + } + + $this->activity = $activity; + + return; + } + + // Fallback: verify ownership via the server that ran the command + $serverUuid = data_get($activity, 'properties.server_uuid'); + if ($serverUuid) { + $server = Server::where('uuid', $serverUuid)->first(); + if ($server && (int) $server->team_id !== (int) $currentTeamId) { + $this->activity = null; + + return; + } + + if ($server) { + $this->activity = $activity; + + return; + } + } + + // Fail closed: no team_id and no server_uuid means we cannot verify ownership + $this->activity = null; } public function polling() diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index b5f6d2929..d1345e7bf 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -6,7 +6,6 @@ use App\Models\User; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Index extends Component @@ -22,16 +21,15 @@ class Index extends Component public function mount() { if (! isCloud() && ! isDev()) { - return redirect()->route('dashboard'); - } - if (Auth::id() !== 0 && ! session('impersonating')) { - return redirect()->route('dashboard'); + abort(403); } + $this->authorizeAdminAccess(); $this->getSubscribers(); } public function back() { + $this->authorizeAdminAccess(); if (session('impersonating')) { session()->forget('impersonating'); $user = User::find(0); @@ -45,6 +43,7 @@ public function back() public function submitSearch() { + $this->authorizeAdminAccess(); if ($this->search !== '') { $this->foundUsers = User::where(function ($query) { $query->where('name', 'like', "%{$this->search}%") @@ -61,19 +60,33 @@ public function getSubscribers() public function switchUser(int $user_id) { - if (Auth::id() !== 0) { - return redirect()->route('dashboard'); - } + $this->authorizeRootOnly(); session(['impersonating' => true]); $user = User::find($user_id); + if (! $user) { + abort(404); + } $team_to_switch_to = $user->teams->first(); - // Cache::forget("team:{$user->id}"); Auth::login($user); refreshSession($team_to_switch_to); return redirect(request()->header('Referer')); } + private function authorizeAdminAccess(): void + { + if (! Auth::check() || (Auth::id() !== 0 && ! session('impersonating'))) { + abort(403); + } + } + + private function authorizeRootOnly(): void + { + if (! Auth::check() || Auth::id() !== 0) { + abort(403); + } + } + public function render() { return view('livewire.admin.index'); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ab1a1aae9..33c75bf70 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -9,6 +9,7 @@ use App\Models\Team; use App\Services\ConfigurationRepository; use Illuminate\Support\Collection; +use Livewire\Attributes\Url; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -19,18 +20,18 @@ class Index extends Component 'prerequisitesInstalled' => 'handlePrerequisitesInstalled', ]; - #[\Livewire\Attributes\Url(as: 'step', history: true)] + #[Url(as: 'step', history: true)] public string $currentState = 'welcome'; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?string $selectedServerType = null; public ?Collection $privateKeys = null; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?int $selectedExistingPrivateKey = null; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?string $privateKeyType = null; public ?string $privateKey = null; @@ -45,7 +46,7 @@ class Index extends Component public ?Collection $servers = null; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?int $selectedExistingServer = null; public ?string $remoteServerName = null; @@ -66,7 +67,7 @@ class Index extends Component public Collection $projects; - #[\Livewire\Attributes\Url(keep: true)] + #[Url(keep: true)] public ?int $selectedProject = null; public ?Project $createdProject = null; @@ -121,7 +122,7 @@ public function mount() } if ($this->selectedExistingServer) { - $this->createdServer = Server::find($this->selectedExistingServer); + $this->createdServer = Server::ownedByCurrentTeam()->find($this->selectedExistingServer); if ($this->createdServer) { $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); $this->updateServerDetails(); @@ -145,7 +146,7 @@ public function mount() } if ($this->selectedProject) { - $this->createdProject = Project::find($this->selectedProject); + $this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject); if (! $this->createdProject) { $this->projects = Project::ownedByCurrentTeam(['name'])->get(); } @@ -283,7 +284,11 @@ public function saveServer() $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { - return $this->dispatch('error', 'IP address is already in use by another team.'); + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } $this->createdServer = Server::create([ 'name' => $this->remoteServerName, @@ -427,7 +432,10 @@ public function getProjects() public function selectExistingProject() { - $this->createdProject = Project::find($this->selectedProject); + $this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject); + if (! $this->createdProject) { + return $this->dispatch('error', 'Project not found.'); + } $this->currentState = 'create-resource'; } diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 70751fa03..6f9b6f995 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -24,7 +24,7 @@ class Docker extends Component #[Validate(['required', 'string'])] public string $name; - #[Validate(['required', 'string'])] + #[Validate(['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])] public string $network; #[Validate(['required', 'string'])] diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 98cf72376..f2cdad074 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -20,7 +20,7 @@ class Show extends Component #[Validate(['string', 'required'])] public string $name; - #[Validate(['string', 'required'])] + #[Validate(['string', 'required', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])] public string $network; #[Validate(['string', 'required'])] @@ -84,8 +84,9 @@ public function delete() if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); } - instant_remote_process(["docker network disconnect {$this->destination->network} coolify-proxy"], $this->destination->server, throwError: false); - instant_remote_process(['docker network rm -f '.$this->destination->network], $this->destination->server); + $safeNetwork = escapeshellarg($this->destination->network); + instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); + instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); } $this->destination->delete(); diff --git a/app/Livewire/ForcePasswordReset.php b/app/Livewire/ForcePasswordReset.php index 61a2a20e9..e6392497f 100644 --- a/app/Livewire/ForcePasswordReset.php +++ b/app/Livewire/ForcePasswordReset.php @@ -48,7 +48,7 @@ public function submit() $this->rateLimit(10); $this->validate(); $firstLogin = auth()->user()->created_at == auth()->user()->updated_at; - auth()->user()->forceFill([ + auth()->user()->fill([ 'password' => Hash::make($this->password), 'force_password_reset' => false, ])->save(); diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index ed9115093..154748b47 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1203,7 +1203,7 @@ public function selectServer($serverId, $shouldProgress = true) public function loadDestinations() { $this->loadingDestinations = true; - $server = Server::find($this->selectedServerId); + $server = Server::ownedByCurrentTeam()->find($this->selectedServerId); if (! $server) { $this->loadingDestinations = false; @@ -1280,7 +1280,7 @@ public function selectProject($projectUuid, $shouldProgress = true) public function loadEnvironments() { $this->loadingEnvironments = true; - $project = Project::where('uuid', $this->selectedProjectUuid)->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->selectedProjectUuid)->first(); if (! $project) { $this->loadingEnvironments = false; @@ -1495,6 +1495,7 @@ public function getServicesProperty() 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', + 'logo' => data_get($service, 'logo'), ]); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index a8c932912..8e5478b5e 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -15,10 +15,10 @@ public function mount() $this->team = currentTeam()->name; } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $currentTeam = currentTeam(); diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index b914fbd94..ab3884320 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -5,6 +5,7 @@ use App\Models\DiscordNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,7 @@ class Discord extends Component #[Validate(['boolean'])] public bool $discordEnabled = false; - #[Validate(['url', 'nullable'])] + #[Validate(['nullable', new SafeWebhookUrl])] public ?string $discordWebhookUrl = null; #[Validate(['boolean'])] diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 847f10765..364163ff8 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -42,7 +42,7 @@ class Email extends Component public ?string $smtpHost = null; #[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])] - public ?int $smtpPort = null; + public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] public ?string $smtpEncryption = null; @@ -54,7 +54,7 @@ class Email extends Component public ?string $smtpPassword = null; #[Validate(['nullable', 'numeric'])] - public ?int $smtpTimeout = null; + public ?string $smtpTimeout = null; #[Validate(['boolean'])] public bool $resendEnabled = false; diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index fa8c97ae9..f870b3986 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -5,6 +5,7 @@ use App\Models\SlackNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -25,7 +26,7 @@ class Slack extends Component #[Validate(['boolean'])] public bool $slackEnabled = false; - #[Validate(['url', 'nullable'])] + #[Validate(['nullable', new SafeWebhookUrl])] public ?string $slackWebhookUrl = null; #[Validate(['boolean'])] diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index 8af70c6eb..630d422a9 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -5,6 +5,7 @@ use App\Models\Team; use App\Models\WebhookNotificationSettings; use App\Notifications\Test; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,7 @@ class Webhook extends Component #[Validate(['boolean'])] public bool $webhookEnabled = false; - #[Validate(['url', 'nullable'])] + #[Validate(['nullable', new SafeWebhookUrl])] public ?string $webhookUrl = null; #[Validate(['boolean'])] diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index 5d7f3fd31..cc1bf15b9 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -51,9 +51,7 @@ public function mount() $this->environment = $environment; $this->application = $application; - if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') { - return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); - } + if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b7c17fcc3..25ce82eb0 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -3,12 +3,14 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\GenerateConfig; +use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Livewire\Attributes\Validate; use Livewire\Component; +use Livewire\Features\SupportEvents\Event; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -22,136 +24,95 @@ class General extends Component public Collection $services; - #[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')] public string $name; - #[Validate(['string', 'nullable'])] public ?string $description = null; - #[Validate(['nullable'])] public ?string $fqdn = null; - #[Validate(['required'])] public string $gitRepository; - #[Validate(['required'])] public string $gitBranch; - #[Validate(['string', 'nullable'])] public ?string $gitCommitSha = null; - #[Validate(['string', 'nullable'])] public ?string $installCommand = null; - #[Validate(['string', 'nullable'])] public ?string $buildCommand = null; - #[Validate(['string', 'nullable'])] public ?string $startCommand = null; - #[Validate(['required'])] public string $buildPack; - #[Validate(['required'])] public string $staticImage; - #[Validate(['required'])] public string $baseDirectory; - #[Validate(['string', 'nullable'])] public ?string $publishDirectory = null; - #[Validate(['string', 'nullable'])] public ?string $portsExposes = null; - #[Validate(['string', 'nullable'])] public ?string $portsMappings = null; - #[Validate(['string', 'nullable'])] public ?string $customNetworkAliases = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfileLocation = null; - #[Validate(['string', 'nullable'])] public ?string $dockerfileTargetBuild = null; - #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageName = null; - #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeLocation = null; - #[Validate(['string', 'nullable'])] public ?string $dockerCompose = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeRaw = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeCustomStartCommand = null; - #[Validate(['string', 'nullable'])] public ?string $dockerComposeCustomBuildCommand = null; - #[Validate(['string', 'nullable'])] public ?string $customDockerRunOptions = null; - #[Validate(['string', 'nullable'])] + // Security: pre/post deployment commands are intentionally arbitrary shell — users need full + // flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization. + // Commands execute inside the application's own container, not on the host. public ?string $preDeploymentCommand = null; - #[Validate(['string', 'nullable'])] public ?string $preDeploymentCommandContainer = null; - #[Validate(['string', 'nullable'])] public ?string $postDeploymentCommand = null; - #[Validate(['string', 'nullable'])] public ?string $postDeploymentCommandContainer = null; - #[Validate(['string', 'nullable'])] public ?string $customNginxConfiguration = null; - #[Validate(['boolean', 'required'])] public bool $isStatic = false; - #[Validate(['boolean', 'required'])] public bool $isSpa = false; - #[Validate(['boolean', 'required'])] public bool $isBuildServerEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isPreserveRepositoryEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isContainerLabelEscapeEnabled = true; - #[Validate(['boolean', 'required'])] public bool $isContainerLabelReadonlyEnabled = false; - #[Validate(['boolean', 'required'])] public bool $isHttpBasicAuthEnabled = false; - #[Validate(['string', 'nullable'])] public ?string $httpBasicAuthUsername = null; - #[Validate(['string', 'nullable'])] public ?string $httpBasicAuthPassword = null; - #[Validate(['nullable'])] public ?string $watchPaths = null; - #[Validate(['string', 'required'])] public string $redirect; - #[Validate(['nullable'])] public $customLabels; public bool $labelsChanged = false; @@ -184,33 +145,33 @@ protected function rules(): array 'fqdn' => 'nullable', 'gitRepository' => 'required', 'gitBranch' => 'required', - 'gitCommitSha' => 'nullable', - 'installCommand' => 'nullable', - 'buildCommand' => 'nullable', - 'startCommand' => 'nullable', + 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], + 'installCommand' => ValidationPatterns::shellSafeCommandRules(), + 'buildCommand' => ValidationPatterns::shellSafeCommandRules(), + 'startCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildPack' => 'required', 'staticImage' => 'required', - 'baseDirectory' => 'required', - 'publishDirectory' => 'nullable', - 'portsExposes' => 'required', - 'portsMappings' => 'nullable', + 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), + 'publishDirectory' => ValidationPatterns::directoryPathRules(), + 'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'], + 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageTag' => 'nullable', - 'dockerfileLocation' => 'nullable', - 'dockerComposeLocation' => 'nullable', + 'dockerfileLocation' => ValidationPatterns::filePathRules(), + 'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerCompose' => 'nullable', 'dockerComposeRaw' => 'nullable', - 'dockerfileTargetBuild' => 'nullable', - 'dockerComposeCustomStartCommand' => 'nullable', - 'dockerComposeCustomBuildCommand' => 'nullable', + 'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(), + 'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(), + 'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(), 'customLabels' => 'nullable', - 'customDockerRunOptions' => 'nullable', + 'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000), 'preDeploymentCommand' => 'nullable', - 'preDeploymentCommandContainer' => 'nullable', + 'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()], 'postDeploymentCommand' => 'nullable', - 'postDeploymentCommandContainer' => 'nullable', + 'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()], 'customNginxConfiguration' => 'nullable', 'isStatic' => 'boolean|required', 'isSpa' => 'boolean|required', @@ -231,6 +192,19 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ + ...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'), + ...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'), + 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.', + 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.', + 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.', + 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', + 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', 'gitRepository.required' => 'The Git Repository field is required.', 'gitBranch.required' => 'The Git Branch field is required.', @@ -238,6 +212,8 @@ protected function messages(): array 'staticImage.required' => 'The Static Image field is required.', 'baseDirectory.required' => 'The Base Directory field is required.', 'portsExposes.required' => 'The Exposed Ports field is required.', + 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).', + ...ValidationPatterns::portMappingMessages(), 'isStatic.required' => 'The Static setting is required.', 'isStatic.boolean' => 'The Static setting must be true or false.', 'isSpa.required' => 'The SPA setting is required.', @@ -320,7 +296,7 @@ public function mount() $this->authorize('update', $this->application); $this->application->fqdn = null; $this->application->settings->save(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User doesn't have update permission, just continue without saving } } @@ -341,7 +317,7 @@ public function mount() $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) { + } catch (AuthorizationException $e) { // User doesn't have update permission, just use existing labels // $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); } @@ -353,7 +329,7 @@ public function mount() $this->authorize('update', $this->application); $this->initLoadingCompose = true; $this->dispatch('info', 'Loading docker compose file.'); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User doesn't have update permission, skip loading compose file } } @@ -619,7 +595,7 @@ public function updatedBuildPack() // Check if user has permission to update try { $this->authorize('update', $this->application); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User doesn't have permission, revert the change and return $this->application->refresh(); $this->syncData(); @@ -644,7 +620,7 @@ public function updatedBuildPack() $this->fqdn = null; $this->application->fqdn = null; $this->application->settings->save(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User doesn't have update permission, just continue without saving } } @@ -761,6 +737,7 @@ public function setRedirect() $this->authorize('update', $this->application); try { + $this->application->redirect = $this->redirect; $has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count(); if ($has_www === 0 && $this->application->redirect === 'www') { $this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.

Please add www to your domain list and as an A DNS record (if applicable).'); @@ -781,6 +758,12 @@ public function submit($showToaster = true) $this->authorize('update', $this->application); $this->resetErrorBag(); + + $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString(); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } + $this->validate(); $oldPortsExposes = $this->application->ports_exposes; @@ -841,7 +824,7 @@ public function submit($showToaster = true) restoreBaseDirectory: $oldBaseDirectory, restoreDockerComposeLocation: $oldDockerComposeLocation ); - if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { + if ($compose_return instanceof Event) { // Validation failed - restore original values to component properties $this->baseDirectory = $oldBaseDirectory; $this->dockerComposeLocation = $oldDockerComposeLocation; @@ -971,7 +954,7 @@ public function getDockerComposeBuildCommandPreviewProperty(): string $command = injectDockerComposeFlags( $this->dockerComposeCustomBuildCommand, ".{$normalizedBase}{$this->dockerComposeLocation}", - \App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH + ApplicationDeploymentJob::BUILD_TIME_ENV_PATH ); // Inject build args if not using build secrets diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 41f352c14..c887e9b83 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -35,8 +35,17 @@ class Previews extends Component public array $previewFqdns = []; + public array $previewDockerTags = []; + + public ?int $manualPullRequestId = null; + + public ?string $manualDockerTag = null; + protected $rules = [ 'previewFqdns.*' => 'string|nullable', + 'previewDockerTags.*' => 'string|nullable', + 'manualPullRequestId' => 'integer|min:1|nullable', + 'manualDockerTag' => 'string|nullable', ]; public function mount() @@ -53,12 +62,17 @@ private function syncData(bool $toModel = false): void $preview = $this->application->previews->get($key); if ($preview) { $preview->fqdn = $fqdn; + if ($this->application->build_pack === 'dockerimage') { + $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; + } } } } else { $this->previewFqdns = []; + $this->previewDockerTags = []; foreach ($this->application->previews as $key => $preview) { $this->previewFqdns[$key] = $preview->fqdn; + $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; } } } @@ -174,7 +188,7 @@ public function generate_preview($preview_id) } } - public function add(int $pull_request_id, ?string $pull_request_html_url = null) + public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null) { try { $this->authorize('update', $this->application); @@ -195,13 +209,18 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found && ! is_null($pull_request_html_url)) { + if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) { $found = ApplicationPreview::create([ 'application_id' => $this->application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', + 'docker_registry_image_tag' => $docker_registry_image_tag, ]); } + if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) { + $found->docker_registry_image_tag = $docker_registry_image_tag; + $found->save(); + } $found->generate_preview_fqdn(); $this->application->refresh(); $this->syncData(false); @@ -217,37 +236,50 @@ public function force_deploy_without_cache(int $pull_request_id, ?string $pull_r { $this->authorize('deploy', $this->application); - $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + $dockerRegistryImageTag = null; + if ($this->application->build_pack === 'dockerimage') { + $dockerRegistryImageTag = $this->application->previews() + ->where('pull_request_id', $pull_request_id) + ->value('docker_registry_image_tag'); + } + + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag); } - public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) + public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null) { $this->authorize('deploy', $this->application); - $this->add($pull_request_id, $pull_request_html_url); - $this->deploy($pull_request_id, $pull_request_html_url); + $this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag); + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag); } - public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false) + public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null) { $this->authorize('deploy', $this->application); try { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found && ! is_null($pull_request_html_url)) { - ApplicationPreview::create([ + if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) { + $found = ApplicationPreview::create([ 'application_id' => $this->application->id, 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + 'pull_request_html_url' => $pull_request_html_url ?? '', + 'docker_registry_image_tag' => $docker_registry_image_tag, ]); } + if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) { + $found->docker_registry_image_tag = $docker_registry_image_tag; + $found->save(); + } $result = queue_application_deployment( application: $this->application, deployment_uuid: $this->deployment_uuid, force_rebuild: $force_rebuild, pull_request_id: $pull_request_id, git_type: $found->git_type ?? null, + docker_registry_image_tag: $docker_registry_image_tag, ); if ($result['status'] === 'queue_full') { $this->dispatch('error', 'Deployment queue full', $result['message']); @@ -277,6 +309,32 @@ protected function setDeploymentUuid() $this->parameters['deployment_uuid'] = $this->deployment_uuid; } + public function addDockerImagePreview() + { + $this->authorize('deploy', $this->application); + $this->validateOnly('manualPullRequestId'); + $this->validateOnly('manualDockerTag'); + + if ($this->application->build_pack !== 'dockerimage') { + $this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.'); + + return; + } + + if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) { + $this->dispatch('error', 'Both pull request id and docker tag are required.'); + + return; + } + + $dockerTag = str($this->manualDockerTag)->trim()->value(); + + $this->add_and_deploy($this->manualPullRequestId, null, $dockerTag); + + $this->manualPullRequestId = null; + $this->manualDockerTag = null; + } + private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index e8edf72fa..3edd77833 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -50,6 +50,8 @@ public function rollbackImage($commit) { $this->authorize('deploy', $this->application); + $commit = validateGitRef($commit, 'rollback commit'); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index ab2517f2b..422dd6b28 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -30,7 +30,7 @@ class Source extends Component #[Validate(['required', 'string'])] public string $gitBranch; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; #[Locked] diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 3b3e42619..644753c83 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -54,7 +54,7 @@ protected function messages(): array public function mount($project_uuid) { $this->project_uuid = $project_uuid; - $this->project = Project::where('uuid', $project_uuid)->firstOrFail(); + $this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); $this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first(); $this->project_id = $this->project->id; $this->servers = currentTeam() @@ -187,6 +187,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $newDatabase->id, @@ -298,9 +299,9 @@ public function clone(string $type) } foreach ($newService->applications() as $application) { - $application->update([ + $application->fill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -315,6 +316,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $application->id, @@ -352,9 +354,9 @@ public function clone(string $type) } foreach ($newService->databases() as $database) { - $database->update([ + $database->fill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -369,6 +371,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $database->id, diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 35262d7b0..a18022882 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -76,7 +76,7 @@ class BackupEdit extends Component public bool $dumpAll = false; #[Validate(['required', 'int', 'min:60', 'max:36000'])] - public int $timeout = 3600; + public int|string $timeout = 3600; public function mount() { @@ -105,21 +105,9 @@ public function syncData(bool $toModel = false) $this->backup->s3_storage_id = $this->s3StorageId; // Validate databases_to_backup to prevent command injection + // Handles all formats including MongoDB's "db:col1,col2|db2:col3" if (filled($this->databasesToBackup)) { - $databases = str($this->databasesToBackup)->explode(','); - foreach ($databases as $index => $db) { - $dbName = trim($db); - try { - validateShellSafePath($dbName, 'database name'); - } catch (\Exception $e) { - // Provide specific error message indicating which database failed validation - $position = $index + 1; - throw new \Exception( - "Database #{$position} ('{$dbName}') validation failed: ". - $e->getMessage() - ); - } - } + validateDatabasesBackupInput($this->databasesToBackup); } $this->backup->databases_to_backup = $this->databasesToBackup; @@ -146,12 +134,12 @@ public function syncData(bool $toModel = false) } } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('manageBackups', $this->backup->database); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 44f903fcc..1dd93781d 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -65,10 +65,10 @@ public function cleanupDeleted() } } - public function deleteBackup($executionId, $password) + public function deleteBackup($executionId, $password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $execution = $this->backup->executions()->where('id', $executionId)->first(); @@ -96,7 +96,11 @@ public function deleteBackup($executionId, $password) $this->refreshBackupExecutions(); } catch (\Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); + + return true; } + + return true; } public function download_file($exeuctionId) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 7ad453fd5..e06629d10 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -34,7 +34,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public ?string $customDockerRunOptions = null; @@ -77,9 +79,10 @@ protected function rules(): array 'clickhouseAdminUser' => 'required|string', 'clickhouseAdminPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -91,6 +94,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'clickhouseAdminUser.required' => 'The Admin User field is required.', 'clickhouseAdminUser.string' => 'The Admin User must be a string.', @@ -99,6 +103,10 @@ protected function messages(): array '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -114,7 +122,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); @@ -130,6 +139,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->dbUrl = $this->database->internal_db_url; @@ -200,6 +210,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 4e325b9ee..5176f5ff9 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -34,7 +34,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public ?string $customDockerRunOptions = null; @@ -55,7 +57,8 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -88,9 +91,10 @@ protected function rules(): array 'description' => ValidationPatterns::descriptionRules(), 'dragonflyPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -103,12 +107,17 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -123,7 +132,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -139,6 +149,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->enable_ssl = $this->database->enable_ssl; @@ -210,6 +221,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -269,8 +283,8 @@ public function regenerateSslCertificate() } SslHelper::generateSslCertificate( - commonName: $existingCert->commonName, - subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], resourceType: $existingCert->resource_type, resourceId: $existingCert->resource_id, serverId: $existingCert->server_id, @@ -286,4 +300,10 @@ public function regenerateSslCertificate() handleError($e, $this); } } + + public function refresh(): void + { + $this->database->refresh(); + $this->syncData(); + } } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 8d3d8e294..c6c9a3c48 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -69,7 +69,11 @@ public function manualCheckStatus() public function mount() { - $this->parameters = get_route_parameters(); + $this->parameters = [ + 'project_uuid' => $this->database->environment->project->uuid, + 'environment_uuid' => $this->database->environment->uuid, + 'database_uuid' => $this->database->uuid, + ]; } public function stop() diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d37bd473..1cdc681cd 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -5,10 +5,12 @@ use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; class Import extends Component @@ -104,17 +106,22 @@ private function validateServerPath(string $path): bool public bool $unsupported = false; // Store IDs instead of models for proper Livewire serialization + #[Locked] public ?int $resourceId = null; + #[Locked] public ?string $resourceType = null; + #[Locked] public ?int $serverId = null; // View-friendly properties to avoid computed property access in Blade + #[Locked] public string $resourceUuid = ''; public string $resourceStatus = ''; + #[Locked] public string $resourceDbType = ''; public array $parameters = []; @@ -135,6 +142,7 @@ private function validateServerPath(string $path): bool public bool $error = false; + #[Locked] public string $container; public array $importCommands = []; @@ -181,7 +189,7 @@ public function server() return null; } - return Server::find($this->serverId); + return Server::ownedByCurrentTeam()->find($this->serverId); } public function getListeners() @@ -401,20 +409,30 @@ public function checkFile() } } - public function runImport() + public function runImport(string $password = ''): bool|string { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + $this->authorize('update', $this->resource); + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + if ($this->filename === '') { $this->dispatch('error', 'Please select a file to import.'); - return; + return true; } if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); - return; + return true; } try { @@ -434,7 +452,7 @@ public function runImport() if (! $this->validateServerPath($this->customLocation)) { $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - return; + return true; } $tmpPath = '/tmp/restore_'.$this->resourceUuid; $escapedCustomLocation = escapeshellarg($this->customLocation); @@ -442,7 +460,7 @@ public function runImport() } else { $this->dispatch('error', 'The file does not exist or has been deleted.'); - return; + return true; } // Copy the restore command to a script file @@ -474,11 +492,15 @@ public function runImport() $this->dispatch('databaserestore'); } } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); + + return true; } finally { $this->filename = null; $this->importCommands = []; } + + return true; } public function loadAvailableS3Storages() @@ -577,26 +599,36 @@ public function checkS3File() } } - public function restoreFromS3() + public function restoreFromS3(string $password = ''): bool|string { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + $this->authorize('update', $this->resource); + if (! ValidationPatterns::isValidContainerName($this->container)) { + $this->dispatch('error', 'Invalid container name.'); + + return true; + } + if (! $this->s3StorageId || blank($this->s3Path)) { $this->dispatch('error', 'Please select S3 storage and provide a path first.'); - return; + return true; } if (is_null($this->s3FileSize)) { $this->dispatch('error', 'Please check the file first by clicking "Check File".'); - return; + return true; } if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); - return; + return true; } try { @@ -613,7 +645,7 @@ public function restoreFromS3() if (! $this->validateBucketName($bucket)) { $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - return; + return true; } // Clean the S3 path @@ -623,7 +655,7 @@ public function restoreFromS3() if (! $this->validateS3Path($cleanPath)) { $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - return; + return true; } // Get helper image @@ -711,9 +743,12 @@ public function restoreFromS3() $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; + handleError($e, $this); - return handleError($e, $this); + return true; } + + return true; } public function buildRestoreCommand(string $tmpPath): string diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index f02aa6674..b50f196a8 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -36,7 +36,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public ?string $customDockerRunOptions = null; @@ -57,7 +59,8 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -91,9 +94,10 @@ protected function rules(): array 'keydbConf' => 'nullable|string', 'keydbPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -108,12 +112,17 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ '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.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -129,7 +138,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -146,6 +156,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->enable_ssl = $this->database->enable_ssl; @@ -217,6 +228,9 @@ public function submit() try { $this->authorize('manageEnvironment', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -262,9 +276,20 @@ public function regenerateSslCertificate() ->where('is_ca_certificate', true) ->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( - commonName: $existingCert->commonName, - subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], resourceType: $existingCert->resource_type, resourceId: $existingCert->resource_id, serverId: $existingCert->server_id, @@ -280,4 +305,10 @@ public function regenerateSslCertificate() handleError($e, $this); } } + + public function refresh(): void + { + $this->database->refresh(); + $this->syncData(); + } } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 74658e2a4..9a1a8bd68 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -42,7 +42,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -59,9 +61,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -76,9 +80,10 @@ protected function rules(): array 'mariadbDatabase' => 'required', 'mariadbConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -89,6 +94,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mariadbRootPassword.required' => 'The Root Password field is required.', @@ -97,6 +103,10 @@ protected function messages(): array 'mariadbDatabase.required' => 'The MariaDB Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -113,6 +123,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', 'enableSsl' => 'Enable SSL', ]; @@ -153,7 +164,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -173,6 +185,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; @@ -205,6 +218,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -281,6 +297,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 9f34b73d5..a21de744a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -40,7 +40,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -59,9 +61,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -75,9 +79,10 @@ protected function rules(): array 'mongoInitdbRootPassword' => 'required', 'mongoInitdbDatabase' => 'required', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -89,6 +94,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', @@ -96,6 +102,10 @@ protected function messages(): array 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); @@ -112,6 +122,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -152,7 +163,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -172,6 +184,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; @@ -205,6 +218,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -289,6 +305,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 86b109251..cacb4ac49 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -42,7 +42,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -61,9 +63,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -78,9 +82,10 @@ protected function rules(): array 'mysqlDatabase' => 'required', 'mysqlConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -92,6 +97,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mysqlRootPassword.required' => 'The Root Password field is required.', @@ -100,6 +106,10 @@ protected function messages(): array 'mysqlDatabase.required' => 'The MySQL Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); @@ -117,6 +127,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -158,7 +169,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -179,6 +191,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; @@ -212,6 +225,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -293,6 +309,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index e24674315..22e350683 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -46,7 +46,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -69,9 +71,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', 'save_init_script', 'delete_init_script', ]; @@ -90,9 +94,10 @@ protected function rules(): array 'postgresConf' => 'nullable', 'initScripts' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -104,6 +109,7 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'postgresUser.required' => 'The Postgres User field is required.', @@ -111,6 +117,10 @@ protected function messages(): array 'postgresDb.required' => 'The Postgres Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); @@ -130,6 +140,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -173,7 +184,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -196,6 +208,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; @@ -256,6 +269,17 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( commonName: $existingCert->common_name, subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], @@ -448,6 +472,9 @@ public function submit() try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -463,4 +490,10 @@ public function submit() } } } + + public function refresh(): void + { + $this->database->refresh(); + $this->syncData(); + } } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 08bcdc343..3c32a6192 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -34,7 +34,9 @@ class General extends Component public ?bool $isPublic = null; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public bool $isLogDrainEnabled = false; @@ -57,9 +59,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', 'envsUpdated' => 'refresh', ]; } @@ -71,9 +75,10 @@ protected function rules(): array 'description' => ValidationPatterns::descriptionRules(), 'redisConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'redisUsername' => 'required', @@ -86,10 +91,15 @@ protected function messages(): array { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPort.min' => 'The Public Port must be at least 1.', + 'publicPort.max' => 'The Public Port must not exceed 65535.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'redisUsername.required' => 'The Redis Username field is required.', 'redisPassword.required' => 'The Redis Password field is required.', ] @@ -104,6 +114,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', @@ -142,7 +153,8 @@ public function syncData(bool $toModel = false) $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; - $this->database->public_port = $this->publicPort; + $this->database->public_port = $this->publicPort ?: null; + $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -158,6 +170,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; @@ -193,6 +206,9 @@ public function submit() try { $this->authorize('manageEnvironment', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } $this->syncData(true); if (version_compare($this->redisVersion, '6.0', '>=')) { @@ -274,9 +290,20 @@ public function regenerateSslCertificate() $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + if (! $caCert) { + $this->server->generateCaCertificate(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + SslHelper::generateSslCertificate( - commonName: $existingCert->commonName, - subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [], + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], resourceType: $existingCert->resource_type, resourceId: $existingCert->resource_id, serverId: $existingCert->server_id, diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index a018046fd..d95041c2d 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -21,7 +21,7 @@ class DeleteProject extends Component public function mount() { $this->parameters = get_route_parameters(); - $this->projectName = Project::findOrFail($this->project_id)->name; + $this->projectName = Project::ownedByCurrentTeam()->findOrFail($this->project_id)->name; } public function delete() @@ -29,7 +29,7 @@ public function delete() $this->validate([ 'project_id' => 'required|int', ]); - $project = Project::findOrFail($this->project_id); + $project = Project::ownedByCurrentTeam()->findOrFail($this->project_id); $this->authorize('delete', $project); if ($project->isEmpty()) { diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 18bb237af..2b92902c6 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -41,8 +41,8 @@ public function submit() // Validate for command injection BEFORE saving to database validateDockerComposeForInjection($this->dockerComposeRaw); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); @@ -63,10 +63,16 @@ public function submit() ]); $variables = parseEnvFormatToArray($this->envFile); - foreach ($variables as $key => $variable) { + foreach ($variables as $key => $data) { + // Extract value and comment from parsed data + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + EnvironmentVariable::create([ 'key' => $key, - 'value' => $variable, + 'value' => $value, + 'comment' => $comment, '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 8aff83153..268333d07 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -121,8 +121,8 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); // Append @sha256 to image name if using digest and not already present $imageName = $parser->getFullImageNameWithoutTag(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 40d2674e2..0222008b0 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -8,6 +8,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; +use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; use Livewire\Component; @@ -75,6 +76,11 @@ public function mount() $this->github_apps = GithubApp::private(); } + public function updatedSelectedRepositoryId(): void + { + $this->loadBranches(); + } + public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { @@ -93,6 +99,8 @@ public function updatedBuildPack() public function loadRepositories($github_app_id) { $this->repositories = collect(); + $this->branches = collect(); + $this->total_branches_count = 0; $this->page = 1; $this->selected_github_app_id = $github_app_id; $this->github_app = GithubApp::where('id', $github_app_id)->first(); @@ -158,10 +166,12 @@ public function submit() 'selected_repository_owner' => $this->selected_repository_owner, 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, ], [ '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], + 'docker_compose_location' => ValidationPatterns::filePathRules(), ]); if ($validator->fails()) { @@ -178,8 +188,8 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); $application = Application::create([ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 77b106200..f8642d6fc 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -11,6 +11,7 @@ use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Support\ValidationPatterns; use Illuminate\Support\Str; use Livewire\Component; use Spatie\Url\Url; @@ -57,15 +58,6 @@ class GithubPrivateRepositoryDeployKey extends Component private ?string $git_repository = null; - protected $rules = [ - '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 [ @@ -75,6 +67,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ValidationPatterns::filePathRules(), ]; } @@ -152,8 +145,8 @@ public function submit() // 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(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); if ($this->git_source === 'other') { $application_init = [ 'name' => generate_random_name(), diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 2fffff6b9..dbfa15a55 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -11,6 +11,7 @@ use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Livewire\Component; use Spatie\Url\Url; @@ -63,16 +64,6 @@ class PublicGitRepository extends Component public bool $new_compose_services = false; - protected $rules = [ - 'repository_url' => ['required', 'string'], - 'port' => 'required|numeric', - 'isStatic' => 'required|boolean', - 'publish_directory' => 'nullable|string', - 'build_pack' => 'required|string', - 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', - ]; - protected function rules() { return [ @@ -82,7 +73,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } @@ -217,13 +208,8 @@ private function getGitSource() if ($this->repository_url_parsed->getSegment(3) === 'tree') { $path = str($this->repository_url_parsed->getPath())->trim('/'); - $this->git_branch = str($path)->after('tree/')->before('/')->value(); - $this->base_directory = str($path)->after($this->git_branch)->after('/')->value(); - if (filled($this->base_directory)) { - $this->base_directory = '/'.$this->base_directory; - } else { - $this->base_directory = '/'; - } + $this->git_branch = str($path)->after('tree/')->value(); + $this->base_directory = '/'; } else { $this->git_branch = 'main'; } @@ -243,10 +229,33 @@ private function getBranch() return; } - if ($this->git_source->getMorphClass() === \App\Models\GithubApp::class) { - ['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$this->git_branch}"); - $this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s'); - $this->branchFound = true; + if ($this->git_source->getMorphClass() === GithubApp::class) { + $originalBranch = $this->git_branch; + $branchToTry = $originalBranch; + + while (true) { + try { + $encodedBranch = urlencode($branchToTry); + ['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$encodedBranch}"); + $this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s'); + $this->git_branch = $branchToTry; + + $remaining = str($originalBranch)->after($branchToTry)->trim('/')->value(); + $this->base_directory = filled($remaining) ? '/'.$remaining : '/'; + + $this->branchFound = true; + + return; + } catch (\Throwable $e) { + if (str_contains($branchToTry, '/')) { + $branchToTry = str($branchToTry)->beforeLast('/')->value(); + + continue; + } + + throw $e; + } + } } } @@ -288,8 +297,8 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $project_uuid)->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); + $environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail(); if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) { $server = $destination->server; diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index c5dc13987..165e4b59e 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -65,7 +65,7 @@ public function mount() $this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432'; } $projectUuid = data_get($this->parameters, 'project_uuid'); - $project = Project::whereUuid($projectUuid)->firstOrFail(); + $project = Project::ownedByCurrentTeam()->whereUuid($projectUuid)->firstOrFail(); $this->environments = $project->environments; $this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name; @@ -79,7 +79,7 @@ public function mount() $this->type = $queryType; $this->server_id = $queryServerId; $this->destination_uuid = $queryDestination; - $this->server = Server::find($queryServerId); + $this->server = Server::ownedByCurrentTeam()->find($queryServerId); $this->current_step = 'select-postgresql-type'; } } catch (\Exception $e) { diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 9cc4fbbe2..1073157e6 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -45,8 +45,8 @@ public function submit() } $destination_class = $destination->getMorphClass(); - $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); - $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); $port = get_port_from_dockerfile($this->dockerfile); if (! $port) { diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index be6e3e98f..094b61b28 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -13,33 +13,33 @@ class Index extends Component public Environment $environment; - public Collection $applications; - - public Collection $postgresqls; - - public Collection $redis; - - public Collection $mongodbs; - - public Collection $mysqls; - - public Collection $mariadbs; - - public Collection $keydbs; - - public Collection $dragonflies; - - public Collection $clickhouses; - - public Collection $services; - public Collection $allProjects; public Collection $allEnvironments; public array $parameters; - public function mount() + protected Collection $applications; + + protected Collection $postgresqls; + + protected Collection $redis; + + protected Collection $mongodbs; + + protected Collection $mysqls; + + protected Collection $mariadbs; + + protected Collection $keydbs; + + protected Collection $dragonflies; + + protected Collection $clickhouses; + + protected Collection $services; + + public function mount(): void { $this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect(); $this->parameters = get_route_parameters(); @@ -55,31 +55,23 @@ public function mount() $this->project = $project; - // Load projects and environments for breadcrumb navigation (avoids inline queries in view) + // Load projects and environments for breadcrumb navigation $this->allProjects = Project::ownedByCurrentTeamCached(); $this->allEnvironments = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') ->with([ - 'applications.additional_servers', - 'applications.destination.server', - 'services', - 'services.destination.server', - 'postgresqls', - 'postgresqls.destination.server', - 'redis', - 'redis.destination.server', - 'mongodbs', - 'mongodbs.destination.server', - 'mysqls', - 'mysqls.destination.server', - 'mariadbs', - 'mariadbs.destination.server', - 'keydbs', - 'keydbs.destination.server', - 'dragonflies', - 'dragonflies.destination.server', - 'clickhouses', - 'clickhouses.destination.server', - ])->get(); + 'applications:id,uuid,name,environment_id', + 'services:id,uuid,name,environment_id', + 'postgresqls:id,uuid,name,environment_id', + 'redis:id,uuid,name,environment_id', + 'mongodbs:id,uuid,name,environment_id', + 'mysqls:id,uuid,name,environment_id', + 'mariadbs:id,uuid,name,environment_id', + 'keydbs:id,uuid,name,environment_id', + 'dragonflies:id,uuid,name,environment_id', + 'clickhouses:id,uuid,name,environment_id', + ]) + ->get(); $this->environment = $environment->loadCount([ 'applications', @@ -94,11 +86,9 @@ public function mount() 'services', ]); - // Eager load all relationships for applications including nested ones + // Eager load relationships for applications $this->applications = $this->environment->applications()->with([ 'tags', - 'additional_servers.settings', - 'additional_networks', 'destination.server.settings', 'settings', ])->get()->sortBy('name'); @@ -160,6 +150,49 @@ public function mount() public function render() { - return view('livewire.project.resource.index'); + return view('livewire.project.resource.index', [ + 'applications' => $this->applications, + 'postgresqls' => $this->postgresqls, + 'redis' => $this->redis, + 'mongodbs' => $this->mongodbs, + 'mysqls' => $this->mysqls, + 'mariadbs' => $this->mariadbs, + 'keydbs' => $this->keydbs, + 'dragonflies' => $this->dragonflies, + 'clickhouses' => $this->clickhouses, + 'services' => $this->services, + 'applicationsJs' => $this->toSearchableArray($this->applications), + 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls), + 'redisJs' => $this->toSearchableArray($this->redis), + 'mongodbsJs' => $this->toSearchableArray($this->mongodbs), + 'mysqlsJs' => $this->toSearchableArray($this->mysqls), + 'mariadbsJs' => $this->toSearchableArray($this->mariadbs), + 'keydbsJs' => $this->toSearchableArray($this->keydbs), + 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies), + 'clickhousesJs' => $this->toSearchableArray($this->clickhouses), + 'servicesJs' => $this->toSearchableArray($this->services), + ]); + } + + private function toSearchableArray(Collection $items): array + { + return $items->map(fn ($item) => [ + 'uuid' => $item->uuid, + 'name' => $item->name, + 'fqdn' => $item->fqdn ?? null, + 'description' => $item->description ?? null, + 'status' => $item->status ?? '', + 'server_status' => $item->server_status ?? null, + 'hrefLink' => $item->hrefLink ?? '', + 'destination' => [ + 'server' => [ + 'name' => $item->destination?->server?->name ?? 'Unknown', + ], + ], + 'tags' => $item->tags->map(fn ($tag) => [ + 'id' => $tag->id, + 'name' => $tag->name, + ])->values()->toArray(), + ])->values()->toArray(); } } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 079115bb6..844e37854 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -40,12 +40,16 @@ class FileStorage extends Component #[Validate(['required', 'boolean'])] public bool $isBasedOnGit = false; + #[Validate(['required', 'boolean'])] + public bool $isPreviewSuffixEnabled = true; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', 'fileStorage.mount_path' => 'required', 'content' => 'nullable', 'isBasedOnGit' => 'required|boolean', + 'isPreviewSuffixEnabled' => 'required|boolean', ]; public function mount() @@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void // Sync to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); } else { // Sync from model $this->content = $this->fileStorage->content; $this->isBasedOnGit = $this->fileStorage->is_based_on_git; + $this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true; } } @@ -134,12 +140,12 @@ public function convertToFile() } } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } try { @@ -158,6 +164,8 @@ public function delete($password) } finally { $this->dispatch('refreshStorages'); } + + return true; } public function submit() @@ -173,6 +181,7 @@ public function submit() // Sync component properties to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); $this->fileStorage->saveStorageOnServer(); $this->dispatch('success', 'File updated.'); @@ -185,9 +194,11 @@ public function submit() } } - public function instantSave() + public function instantSave(): void { - $this->submit(); + $this->authorize('update', $this->resource); + $this->syncData(true); + $this->dispatch('success', 'File updated.'); } public function render() diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 360282911..cb2d977bc 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -51,7 +51,9 @@ class Index extends Component public bool $excludeFromStatus = false; - public ?int $publicPort = null; + public mixed $publicPort = null; + + public mixed $publicPortTimeout = 3600; public bool $isPublic = false; @@ -89,7 +91,8 @@ class Index extends Component 'description' => 'nullable', 'image' => 'required', 'excludeFromStatus' => 'required|boolean', - 'publicPort' => 'nullable|integer', + 'publicPort' => 'nullable|integer|min:1|max:65535', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', // Application-specific rules @@ -157,7 +160,8 @@ private function syncDatabaseData(bool $toModel = false): void $this->serviceDatabase->description = $this->description; $this->serviceDatabase->image = $this->image; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; - $this->serviceDatabase->public_port = $this->publicPort; + $this->serviceDatabase->public_port = $this->publicPort ?: null; + $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout ?: null; $this->serviceDatabase->is_public = $this->isPublic; $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; } else { @@ -166,6 +170,7 @@ private function syncDatabaseData(bool $toModel = false): void $this->image = $this->serviceDatabase->image; $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; $this->publicPort = $this->serviceDatabase->public_port; + $this->publicPortTimeout = $this->serviceDatabase->public_port_timeout; $this->isPublic = $this->serviceDatabase->is_public ?? false; $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; } @@ -189,13 +194,13 @@ public function refreshFileStorages() } } - public function deleteDatabase($password) + public function deleteDatabase($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceDatabase); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->serviceDatabase->delete(); @@ -393,13 +398,13 @@ public function instantSaveApplicationAdvanced() } } - public function deleteApplication($password) + public function deleteApplication($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceApplication); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->serviceApplication->delete(); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 12d8bcbc3..433c2b13c 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -2,7 +2,10 @@ namespace App\Livewire\Project\Service; +use App\Models\Application; +use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -49,7 +52,7 @@ public function mount() $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; } - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { if ($this->resource->destination->server->isSwarm()) { $this->isSwarm = true; } @@ -101,10 +104,10 @@ public function submitPersistentVolume() $this->authorize('update', $this->resource); $this->validate([ - 'name' => 'required|string', + 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable', - ]); + ], ValidationPatterns::volumeNameMessages()); $name = $this->resource->uuid.'-'.$this->name; @@ -138,7 +141,10 @@ public function submitFileStorage() $this->file_storage_path = trim($this->file_storage_path); $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + // Validate path to prevent command injection + validateShellSafePath($this->file_storage_path, 'file storage path'); + + if ($this->resource->getMorphClass() === Application::class) { $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; @@ -146,7 +152,7 @@ public function submitFileStorage() throw new \Exception('No valid resource type for file mount storage type!'); } - \App\Models\LocalFileVolume::create([ + LocalFileVolume::create([ 'fs_path' => $fs_path, 'mount_path' => $this->file_storage_path, 'content' => $this->file_storage_content, @@ -183,7 +189,7 @@ public function submitFileStorageDirectory() validateShellSafePath($this->file_storage_directory_source, 'storage source path'); validateShellSafePath($this->file_storage_directory_destination, 'storage destination path'); - \App\Models\LocalFileVolume::create([ + LocalFileVolume::create([ 'fs_path' => $this->file_storage_directory_source, 'mount_path' => $this->file_storage_directory_destination, 'is_directory' => true, diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 1b15c6367..caaabc494 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -45,10 +45,10 @@ public function mount() if ($this->resource === null) { if (isset($parameters['service_uuid'])) { - $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $parameters['service_uuid'])->first(); } elseif (isset($parameters['stack_service_uuid'])) { - $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() - ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + $this->resource = ServiceApplication::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first(); } } @@ -88,16 +88,21 @@ public function mount() } } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if (! $this->resource) { - $this->addError('resource', 'Resource not found.'); + return 'Resource not found.'; + } - return; + if (! empty($selectedActions)) { + $this->delete_volumes = in_array('delete_volumes', $selectedActions); + $this->delete_connected_networks = in_array('delete_connected_networks', $selectedActions); + $this->delete_configurations = in_array('delete_configurations', $selectedActions); + $this->docker_cleanup = in_array('docker_cleanup', $selectedActions); } try { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 7ab81b7d1..363471760 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -134,11 +134,11 @@ public function addServer(int $network_id, int $server_id) $this->dispatch('refresh'); } - public function removeServer(int $network_id, int $server_id, $password) + public function removeServer(int $network_id, int $server_id, $password, $selectedActions = []) { try { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { @@ -152,6 +152,8 @@ public function removeServer(int $network_id, int $server_id, $password) $this->loadData(); $this->dispatch('refresh'); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); + + return true; } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index fa65e8bd2..c51b27b6a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -31,6 +31,8 @@ class Add extends Component public bool $is_buildtime = true; + public ?string $comment = null; + public array $problematicVariables = []; protected $listeners = ['clearAddEnv' => 'clear']; @@ -42,6 +44,7 @@ class Add extends Component 'is_literal' => 'required|boolean', 'is_runtime' => 'required|boolean', 'is_buildtime' => 'required|boolean', + 'comment' => 'nullable|string|max:256', ]; protected $validationAttributes = [ @@ -51,6 +54,7 @@ class Add extends Component 'is_literal' => 'literal', 'is_runtime' => 'runtime', 'is_buildtime' => 'buildtime', + 'comment' => 'comment', ]; public function mount() @@ -67,6 +71,7 @@ public function availableSharedVariables(): array 'team' => [], 'project' => [], 'environment' => [], + 'server' => [], ]; // Early return if no team @@ -122,6 +127,66 @@ public function availableSharedVariables(): array } } + // Get server variables + $serverUuid = data_get($this->parameters, 'server_uuid'); + if ($serverUuid) { + // If we have a specific server_uuid, show variables for that server + $server = \App\Models\Server::where('team_id', $team->id) + ->where('uuid', $serverUuid) + ->first(); + + if ($server) { + try { + $this->authorize('view', $server); + $result['server'] = $server->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view server variables + } + } + } else { + // For application environment variables, try to use the application's destination server + $applicationUuid = data_get($this->parameters, 'application_uuid'); + if ($applicationUuid) { + $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + ->where('uuid', $applicationUuid) + ->with('destination.server') + ->first(); + + if ($application && $application->destination && $application->destination->server) { + try { + $this->authorize('view', $application->destination->server); + $result['server'] = $application->destination->server->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view server variables + } + } + } else { + // For service environment variables, try to use the service's server + $serviceUuid = data_get($this->parameters, 'service_uuid'); + if ($serviceUuid) { + $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + ->where('uuid', $serviceUuid) + ->with('server') + ->first(); + + if ($service && $service->server) { + try { + $this->authorize('view', $service->server); + $result['server'] = $service->server->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view server variables + } + } + } + } + } + return $result; } @@ -136,6 +201,7 @@ public function submit() 'is_runtime' => $this->is_runtime, 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, + 'comment' => $this->comment, ]); $this->clear(); } @@ -148,5 +214,6 @@ public function clear() $this->is_literal = false; $this->is_runtime = true; $this->is_buildtime = true; + $this->comment = null; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 55e388b78..f250a860b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -89,6 +89,62 @@ public function getEnvironmentVariablesPreviewProperty() return $query->get(); } + public function getHardcodedEnvironmentVariablesProperty() + { + return $this->getHardcodedVariables(false); + } + + public function getHardcodedEnvironmentVariablesPreviewProperty() + { + return $this->getHardcodedVariables(true); + } + + protected function getHardcodedVariables(bool $isPreview) + { + // Only for services and docker-compose applications + if ($this->resource->type() !== 'service' && + ($this->resourceClass !== 'App\Models\Application' || + ($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) { + return collect([]); + } + + $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + + if (blank($dockerComposeRaw)) { + return collect([]); + } + + // Extract all hard-coded variables + $hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw); + + // Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*) + $hardcodedVars = $hardcodedVars->filter(function ($var) { + $key = $var['key']; + + return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']); + }); + + // Filter out variables that exist in database (user has overridden/managed them) + // For preview, check against preview variables; for production, check against production variables + if ($isPreview) { + $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray(); + } else { + $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray(); + } + + $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) { + return ! in_array($var['key'], $managedKeys); + }); + + // Apply sorting based on is_env_sorting_enabled + if ($this->is_env_sorting_enabled) { + $hardcodedVars = $hardcodedVars->sortBy('key')->values(); + } + // Otherwise keep order from docker-compose file + + return $hardcodedVars; + } + public function getDevView() { $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); @@ -240,6 +296,7 @@ private function createEnvironmentVariable($data) $environment->is_runtime = $data['is_runtime'] ?? true; $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; + $environment->comment = $data['comment'] ?? null; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); @@ -280,18 +337,37 @@ private function deleteRemovedVariables($isPreview, $variables) private function updateOrCreateVariables($isPreview, $variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } + + // Extract value and comment from parsed data + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $found = $this->resource->$method()->where('key', $key)->first(); if ($found) { if (! $found->is_shown_once && ! $found->is_multiline) { - // Only count as a change if the value actually changed + $changed = false; + + // Update value if it changed if ($found->value !== $value) { $found->value = $value; + $changed = true; + } + + // Only update comment from inline comment if one is provided (overwrites existing) + // If $comment is null, don't touch existing comment field to preserve it + if ($comment !== null && $found->comment !== $comment) { + $found->comment = $comment; + $changed = true; + } + + if ($changed) { $found->save(); $count++; } @@ -300,6 +376,7 @@ private function updateOrCreateVariables($isPreview, $variables) $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; + $environment->comment = $comment; // Set comment from inline comment $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2030f631e..4e8521f27 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -24,6 +24,8 @@ class Show extends Component public bool $isLocked = false; + public bool $isMagicVariable = false; + public bool $isSharedVariable = false; public string $type; @@ -34,6 +36,8 @@ class Show extends Component public ?string $real_value = null; + public ?string $comment = null; + public bool $is_shared = false; public bool $is_multiline = false; @@ -63,6 +67,7 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -93,6 +98,9 @@ public function getResourceProperty() public function refresh() { + if (! $this->env->exists || ! $this->env->fresh()) { + return; + } $this->syncData(); $this->checkEnvs(); } @@ -104,6 +112,7 @@ public function syncData(bool $toModel = false) $this->validate([ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -118,6 +127,7 @@ public function syncData(bool $toModel = false) } $this->env->key = $this->key; $this->env->value = $this->value; + $this->env->comment = $this->comment; $this->env->is_multiline = $this->is_multiline; $this->env->is_literal = $this->is_literal; $this->env->is_shown_once = $this->is_shown_once; @@ -125,6 +135,7 @@ public function syncData(bool $toModel = false) } else { $this->key = $this->env->key; $this->value = $this->env->value; + $this->comment = $this->env->comment; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; @@ -140,9 +151,13 @@ public function syncData(bool $toModel = false) public function checkEnvs() { $this->isDisabled = false; + $this->isMagicVariable = false; + 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; + $this->isMagicVariable = true; } + if ($this->env->is_shown_once) { $this->isLocked = true; } @@ -204,6 +219,7 @@ public function availableSharedVariables(): array 'team' => [], 'project' => [], 'environment' => [], + 'server' => [], ]; // Early return if no team @@ -259,6 +275,66 @@ public function availableSharedVariables(): array } } + // Get server variables + $serverUuid = data_get($this->parameters, 'server_uuid'); + if ($serverUuid) { + // If we have a specific server_uuid, show variables for that server + $server = \App\Models\Server::where('team_id', $team->id) + ->where('uuid', $serverUuid) + ->first(); + + if ($server) { + try { + $this->authorize('view', $server); + $result['server'] = $server->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view server variables + } + } + } else { + // For application environment variables, try to use the application's destination server + $applicationUuid = data_get($this->parameters, 'application_uuid'); + if ($applicationUuid) { + $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + ->where('uuid', $applicationUuid) + ->with('destination.server') + ->first(); + + if ($application && $application->destination && $application->destination->server) { + try { + $this->authorize('view', $application->destination->server); + $result['server'] = $application->destination->server->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view server variables + } + } + } else { + // For service environment variables, try to use the service's server + $serviceUuid = data_get($this->parameters, 'service_uuid'); + if ($serviceUuid) { + $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + ->where('uuid', $serviceUuid) + ->with('server') + ->first(); + + if ($service && $service->server) { + try { + $this->authorize('view', $service->server); + $result['server'] = $service->server->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view server variables + } + } + } + } + } + return $result; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php new file mode 100644 index 000000000..3a49ce124 --- /dev/null +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -0,0 +1,31 @@ +key = $this->env['key']; + $this->value = $this->env['value'] ?? null; + $this->comment = $this->env['comment'] ?? null; + $this->serviceName = $this->env['service_name'] ?? null; + } + + public function render() + { + return view('livewire.project.shared.environment-variable.show-hardcoded'); + } +} diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 02062e1f7..4ea5e12db 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -5,6 +5,7 @@ use App\Models\Application; use App\Models\Server; use App\Models\Service; +use App\Support\ValidationPatterns; use Illuminate\Support\Collection; use Livewire\Attributes\On; use Livewire\Component; @@ -38,7 +39,7 @@ public function mount() $this->servers = collect(); if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; - $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -61,14 +62,14 @@ public function mount() $this->loadContainers(); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; - $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } $this->loadContainers(); } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; - $this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail(); + $this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail(); $this->servers = $this->servers->push($this->resource); } $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); @@ -181,7 +182,7 @@ public function connectToContainer() } try { // Validate container name format - if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $this->selected_container)) { + if (! ValidationPatterns::isValidContainerName($this->selected_container)) { throw new \InvalidArgumentException('Invalid container name format'); } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 22605e1bb..d0121bdc5 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -16,7 +16,9 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Process; +use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component @@ -29,12 +31,16 @@ class GetLogs extends Component public string $errors = ''; + #[Locked] public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|null $resource = null; + #[Locked] public ServiceApplication|ServiceDatabase|null $servicesubtype = null; + #[Locked] public Server $server; + #[Locked] public ?string $container = null; public ?string $displayName = null; @@ -54,7 +60,7 @@ class GetLogs extends Component public function mount() { if (! is_null($this->resource)) { - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $this->showTimeStamps = $this->resource->settings->is_include_timestamps; } else { if ($this->servicesubtype) { @@ -63,7 +69,7 @@ public function mount() $this->showTimeStamps = $this->resource->is_include_timestamps; } } - if ($this->resource?->getMorphClass() === \App\Models\Application::class) { + if ($this->resource?->getMorphClass() === Application::class) { if (str($this->container)->contains('-pr-')) { $this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value(); } @@ -74,11 +80,11 @@ public function mount() public function instantSave() { if (! is_null($this->resource)) { - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); } - if ($this->resource->getMorphClass() === \App\Models\Service::class) { + if ($this->resource->getMorphClass() === Service::class) { $serviceName = str($this->container)->beforeLast('-')->value(); $subType = $this->resource->applications()->where('name', $serviceName)->first(); if ($subType) { @@ -118,10 +124,20 @@ public function toggleStreamLogs() public function getLogs($refresh = false) { + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + $this->outputs = 'Unauthorized.'; + + return; + } if (! $this->server->isFunctional()) { return; } - if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { + if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) { + $this->outputs = 'Invalid container name.'; + + return; + } + if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { return; } if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { @@ -194,9 +210,15 @@ public function copyLogs(): string public function downloadAllLogs(): string { + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + return ''; + } if (! $this->server->isFunctional() || ! $this->container) { return ''; } + if (! ValidationPatterns::isValidContainerName($this->container)) { + return ''; + } if ($this->showTimeStamps) { if ($this->server->isSwarm()) { diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 05f786690..195e7fd92 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -16,19 +16,25 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $healthCheckEnabled = false; - #[Validate(['string'])] + #[Validate(['string', 'in:http,cmd'])] + public string $healthCheckType = 'http'; + + #[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])] + public ?string $healthCheckCommand = null; + + #[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])] public string $healthCheckMethod; - #[Validate(['string'])] + #[Validate(['required', 'string', 'in:http,https'])] public string $healthCheckScheme; - #[Validate(['string'])] + #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])] public string $healthCheckHost; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])] public ?string $healthCheckPort = null; - #[Validate(['string'])] + #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'])] public string $healthCheckPath; #[Validate(['integer'])] @@ -54,12 +60,14 @@ class HealthChecks extends Component protected $rules = [ 'healthCheckEnabled' => 'boolean', - 'healthCheckPath' => 'string', - 'healthCheckPort' => 'nullable|string', - 'healthCheckHost' => 'string', - 'healthCheckMethod' => 'string', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], + 'healthCheckPort' => 'nullable|integer|min:1|max:65535', + 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', 'healthCheckReturnCode' => 'integer', - 'healthCheckScheme' => 'string', + 'healthCheckScheme' => 'required|string|in:http,https', 'healthCheckResponseText' => 'nullable|string', 'healthCheckInterval' => 'integer|min:1', 'healthCheckTimeout' => 'integer|min:1', @@ -81,6 +89,8 @@ public function syncData(bool $toModel = false): void // Sync to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -98,6 +108,8 @@ public function syncData(bool $toModel = false): void } else { // Sync from model $this->healthCheckEnabled = $this->resource->health_check_enabled; + $this->healthCheckType = $this->resource->health_check_type ?? 'http'; + $this->healthCheckCommand = $this->resource->health_check_command; $this->healthCheckMethod = $this->resource->health_check_method; $this->healthCheckScheme = $this->resource->health_check_scheme; $this->healthCheckHost = $this->resource->health_check_host; @@ -116,9 +128,12 @@ public function syncData(bool $toModel = false): void public function instantSave() { $this->authorize('update', $this->resource); + $this->validate(); // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -143,6 +158,8 @@ public function submit() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -171,6 +188,8 @@ public function toggleHealthcheck() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index 6c4aadd39..a95259c71 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -106,7 +106,7 @@ public function mount() $this->query = request()->query(); if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; - $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); $this->status = $this->resource->status; if ($this->resource->destination->server->isFunctional()) { $server = $this->resource->destination->server; @@ -133,7 +133,7 @@ public function mount() $this->containers->push($this->container); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; - $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); $this->resource->applications()->get()->each(function ($application) { $this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid')); }); diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 0b3840289..8a14dc10c 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Component; class ResourceLimits extends Component @@ -16,24 +17,24 @@ class ResourceLimits extends Component public ?string $limitsCpuset = null; - public ?int $limitsCpuShares = null; + public mixed $limitsCpuShares = null; public string $limitsMemory; public string $limitsMemorySwap; - public int $limitsMemorySwappiness; + public mixed $limitsMemorySwappiness = 0; public string $limitsMemoryReservation; protected $rules = [ - 'limitsMemory' => 'required|string', - 'limitsMemorySwap' => 'required|string', + 'limitsMemory' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], + 'limitsMemorySwap' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], 'limitsMemorySwappiness' => 'required|integer|min:0|max:100', - 'limitsMemoryReservation' => 'required|string', - 'limitsCpus' => 'nullable', - 'limitsCpuset' => 'nullable', - 'limitsCpuShares' => 'nullable', + 'limitsMemoryReservation' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], + 'limitsCpus' => ['nullable', 'regex:/^\d*\.?\d+$/'], + 'limitsCpuset' => ['nullable', 'regex:/^\d+([,-]\d+)*$/'], + 'limitsCpuShares' => 'nullable|integer|min:0', ]; protected $validationAttributes = [ @@ -46,6 +47,19 @@ class ResourceLimits extends Component 'limitsCpuShares' => 'cpu shares', ]; + protected $messages = [ + 'limitsMemory.regex' => 'Maximum Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsMemorySwap.regex' => 'Maximum Swap Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsMemoryReservation.regex' => 'Soft Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsCpus.regex' => 'Number of CPUs must be a number (integer or decimal). Example: 0.5, 2.', + 'limitsCpuset.regex' => 'CPU sets must be a comma-separated list of CPU numbers or ranges. Example: 0-2 or 0,1,3.', + 'limitsMemorySwappiness.integer' => 'Swappiness must be a whole number between 0 and 100.', + 'limitsMemorySwappiness.min' => 'Swappiness must be between 0 and 100.', + 'limitsMemorySwappiness.max' => 'Swappiness must be between 0 and 100.', + 'limitsCpuShares.integer' => 'CPU Weight must be a whole number.', + 'limitsCpuShares.min' => 'CPU Weight must be a positive number.', + ]; + /** * Sync data between component properties and model * @@ -57,10 +71,10 @@ private function syncData(bool $toModel = false): void // Sync TO model (before save) $this->resource->limits_cpus = $this->limitsCpus; $this->resource->limits_cpuset = $this->limitsCpuset; - $this->resource->limits_cpu_shares = $this->limitsCpuShares; + $this->resource->limits_cpu_shares = (int) $this->limitsCpuShares; $this->resource->limits_memory = $this->limitsMemory; $this->resource->limits_memory_swap = $this->limitsMemorySwap; - $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness; + $this->resource->limits_memory_swappiness = (int) $this->limitsMemorySwappiness; $this->resource->limits_memory_reservation = $this->limitsMemoryReservation; } else { // Sync FROM model (on load/refresh) @@ -91,7 +105,7 @@ public function submit() if (! $this->limitsMemorySwap) { $this->limitsMemorySwap = '0'; } - if (is_null($this->limitsMemorySwappiness)) { + if ($this->limitsMemorySwappiness === '' || is_null($this->limitsMemorySwappiness)) { $this->limitsMemorySwappiness = 60; } if (! $this->limitsMemoryReservation) { @@ -103,7 +117,7 @@ public function submit() if ($this->limitsCpuset === '') { $this->limitsCpuset = null; } - if (is_null($this->limitsCpuShares)) { + if ($this->limitsCpuShares === '' || is_null($this->limitsCpuShares)) { $this->limitsCpuShares = 1024; } @@ -112,6 +126,12 @@ public function submit() $this->syncData(true); $this->resource->save(); $this->dispatch('success', 'Resource limits updated.'); + } catch (ValidationException $e) { + foreach ($e->validator->errors()->all() as $message) { + $this->dispatch('error', $message); + } + + return; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 4ba961dfd..f4813dd4c 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -7,9 +7,18 @@ use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Jobs\VolumeCloneJob; +use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; +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 App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -49,9 +58,10 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $new_destination = StandaloneDocker::find($destination_id); + $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); + $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::find($destination_id); + $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); @@ -59,7 +69,7 @@ public function cloneTo($destination_id) $uuid = (string) new Cuid2; $server = $new_destination->server; - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); $route = route('project.application.configuration', [ @@ -70,14 +80,14 @@ public function cloneTo($destination_id) return redirect()->to($route); } elseif ( - $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $this->resource->getMorphClass() === StandalonePostgresql::class || + $this->resource->getMorphClass() === StandaloneMongodb::class || + $this->resource->getMorphClass() === StandaloneMysql::class || + $this->resource->getMorphClass() === StandaloneMariadb::class || + $this->resource->getMorphClass() === StandaloneRedis::class || + $this->resource->getMorphClass() === StandaloneKeydb::class || + $this->resource->getMorphClass() === StandaloneDragonfly::class || + $this->resource->getMorphClass() === StandaloneClickhouse::class ) { $uuid = (string) new Cuid2; $new_resource = $this->resource->replicate([ @@ -132,6 +142,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $new_resource->id, @@ -253,9 +264,9 @@ public function cloneTo($destination_id) } foreach ($new_resource->applications() as $application) { - $application->update([ + $application->fill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -270,6 +281,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $application->id, @@ -295,9 +307,9 @@ public function cloneTo($destination_id) } foreach ($new_resource->databases() as $database) { - $database->update([ + $database->fill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -312,6 +324,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $database->id, @@ -352,10 +365,10 @@ public function moveTo($environment_id) { try { $this->authorize('update', $this->resource); - $new_environment = Environment::findOrFail($environment_id); - $this->resource->update([ + $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); + $this->resource->fill([ 'environment_id' => $environment_id, - ]); + ])->save(); if ($this->resource->type() === 'application') { $route = route('project.application.configuration', [ 'project_uuid' => $new_environment->project->uuid, diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index b1b34dd71..882737f09 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -52,18 +52,15 @@ class Show extends Component #[Locked] public string $task_uuid; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - - public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null) + public function mount() { try { + $task_uuid = request()->route('task_uuid'); + $project_uuid = request()->route('project_uuid'); + $environment_uuid = request()->route('environment_uuid'); + $application_uuid = request()->route('application_uuid'); + $service_uuid = request()->route('service_uuid'); + $this->task_uuid = $task_uuid; if ($application_uuid) { $this->type = 'application'; @@ -114,6 +111,19 @@ public function syncData(bool $toModel = false) } } + public function toggleEnabled() + { + try { + $this->authorize('update', $this->resource); + $this->isEnabled = ! $this->isEnabled; + $this->task->enabled = $this->isEnabled; + $this->task->save(); + $this->dispatch('success', $this->isEnabled ? 'Scheduled task enabled.' : 'Scheduled task disabled.'); + } catch (\Exception $e) { + return handleError($e); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 2091eca14..eee5a0776 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -29,10 +29,13 @@ class Show extends Component public ?string $hostPath = null; + public bool $isPreviewSuffixEnabled = true; + protected $rules = [ 'name' => 'required|string', 'mountPath' => 'required|string', 'hostPath' => 'string|nullable', + 'isPreviewSuffixEnabled' => 'required|boolean', ]; protected $validationAttributes = [ @@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void $this->storage->name = $this->name; $this->storage->mount_path = $this->mountPath; $this->storage->host_path = $this->hostPath; + $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; } else { // Sync FROM model (on load/refresh) $this->name = $this->storage->name; $this->mountPath = $this->storage->mount_path; $this->hostPath = $this->storage->host_path; + $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true; } } @@ -67,6 +72,16 @@ public function mount() $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } + public function instantSave(): void + { + $this->authorize('update', $this->resource); + $this->validate(); + + $this->syncData(true); + $this->storage->save(); + $this->dispatch('success', 'Storage updated successfully'); + } + public function submit() { $this->authorize('update', $this->resource); @@ -77,15 +92,17 @@ public function submit() $this->dispatch('success', 'Storage updated successfully'); } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->storage->delete(); $this->dispatch('refreshStorages'); + + return true; } } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index ae68b2354..bbc2b3e66 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -4,6 +4,7 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Server; +use App\Support\ValidationPatterns; use Livewire\Attributes\On; use Livewire\Component; @@ -36,7 +37,7 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid) if ($isContainer) { // Validate container identifier format (alphanumeric, dashes, and underscores only) - if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $identifier)) { + if (! ValidationPatterns::isValidContainerName($identifier)) { throw new \InvalidArgumentException('Invalid container identifier format'); } diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index dba1b4903..b39da5e5a 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -15,17 +15,17 @@ class Advanced extends Component #[Validate(['string'])] public string $serverDiskUsageCheckFrequency = '0 23 * * *'; - #[Validate(['integer', 'min:1', 'max:99'])] - public int $serverDiskUsageNotificationThreshold = 50; + #[Validate(['required', 'integer', 'min:1', 'max:99'])] + public int|string $serverDiskUsageNotificationThreshold = 50; - #[Validate(['integer', 'min:1'])] - public int $concurrentBuilds = 1; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $concurrentBuilds = 1; - #[Validate(['integer', 'min:1'])] - public int $dynamicTimeout = 1; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $dynamicTimeout = 1; - #[Validate(['integer', 'min:1'])] - public int $deploymentQueueLimit = 25; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $deploymentQueueLimit = 25; public function mount(string $server_uuid) { diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php index c929d9b3d..57aaaa945 100644 --- a/app/Livewire/Server/CaCertificate/Show.php +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -60,10 +60,16 @@ public function saveCaCertificate() throw new \Exception('Certificate content cannot be empty.'); } - if (! openssl_x509_read($this->certificateContent)) { + $parsedCert = openssl_x509_read($this->certificateContent); + if (! $parsedCert) { throw new \Exception('Invalid certificate format.'); } + if (! openssl_x509_export($parsedCert, $cleanedCertificate)) { + throw new \Exception('Failed to process certificate.'); + } + $this->certificateContent = $cleanedCertificate; + if ($this->caCertificate) { $this->caCertificate->ssl_certificate = $this->certificateContent; $this->caCertificate->save(); @@ -114,12 +120,14 @@ private function writeCertificateToServer() { $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($this->certificateContent); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index e7b64b805..d06543b39 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\DeleteServer; +use App\Jobs\DeleteResourceJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -15,6 +16,8 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $force_delete_resources = false; + public function mount(string $server_uuid) { try { @@ -24,19 +27,30 @@ public function mount(string $server_uuid) } } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; + } + + if (! empty($selectedActions)) { + $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { $this->authorize('delete', $this->server); - if ($this->server->hasDefinedResources()) { - $this->dispatch('error', 'Server has defined resources. Please delete them first.'); + if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) { + $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".'); return; } + if ($this->force_delete_resources) { + foreach ($this->server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $this->server->delete(); DeleteServer::dispatch( $this->server->id, @@ -56,6 +70,15 @@ public function render() { $checkboxes = []; + if ($this->server->hasDefinedResources()) { + $resourceCount = $this->server->definedResources()->count(); + $checkboxes[] = [ + 'id' => 'force_delete_resources', + 'label' => "Delete all resources ({$resourceCount} total)", + 'default_warning' => 'Server cannot be deleted while it has resources.', + ]; + } + if ($this->server->hetzner_server_id) { $checkboxes[] = [ 'id' => 'delete_from_hetzner', diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 92094c950..12d111d21 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -3,8 +3,13 @@ namespace App\Livewire\Server; use App\Jobs\DockerCleanupJob; +use App\Models\DockerCleanupExecution; use App\Models\Server; +use Cron\CronExpression; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; +use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; @@ -34,6 +39,53 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $disableApplicationImageRetention = false; + #[Computed] + public function isCleanupStale(): bool + { + try { + $lastExecution = DockerCleanupExecution::where('server_id', $this->server->id) + ->orderBy('created_at', 'desc') + ->first(); + + if (! $lastExecution) { + return false; + } + + $frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *'; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $cron = new CronExpression($frequency); + $now = Carbon::now(); + $nextRun = Carbon::parse($cron->getNextRunDate($now)); + $afterThat = Carbon::parse($cron->getNextRunDate($nextRun)); + $intervalMinutes = $nextRun->diffInMinutes($afterThat); + + $threshold = max($intervalMinutes * 2, 10); + + return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold; + } catch (\Throwable) { + return false; + } + } + + #[Computed] + public function lastExecutionTime(): ?string + { + return DockerCleanupExecution::where('server_id', $this->server->id) + ->orderBy('created_at', 'desc') + ->first() + ?->created_at + ?->diffForHumans(); + } + + #[Computed] + public function isSchedulerHealthy(): bool + { + return Cache::get('scheduled-job-manager:heartbeat') !== null; + } + public function mount(string $server_uuid) { try { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index d4a65af81..5d77f4998 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -24,16 +24,16 @@ class LogDrains extends Component #[Validate(['boolean'])] public bool $isLogDrainAxiomEnabled = false; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainNewRelicLicenseKey = null; #[Validate(['url', 'nullable'])] public ?string $logDrainNewRelicBaseUri = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomDatasetName = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomApiKey = null; #[Validate(['string', 'nullable'])] @@ -127,7 +127,7 @@ public function customValidation() if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ - 'logDrainNewRelicLicenseKey' => ['required'], + 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logDrainNewRelicBaseUri' => ['required', 'url'], ]); } catch (\Throwable $e) { @@ -138,8 +138,8 @@ public function customValidation() } elseif ($this->isLogDrainAxiomEnabled) { try { $this->validate([ - 'logDrainAxiomDatasetName' => ['required'], - 'logDrainAxiomApiKey' => ['required'], + 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], ]); } catch (\Throwable $e) { $this->isLogDrainAxiomEnabled = false; diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index f1ffa60f2..4c6f31b0c 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -8,6 +8,7 @@ use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; +use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -161,7 +162,7 @@ protected function rules(): array 'selectedHetznerSshKeyIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', - 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml], + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', @@ -295,11 +296,6 @@ private function getCpuVendorInfo(array $serverType): ?string public function getAvailableServerTypesProperty() { - ray('Getting available server types', [ - 'selected_location' => $this->selected_location, - 'total_server_types' => count($this->serverTypes), - ]); - if (! $this->selected_location) { return $this->serverTypes; } @@ -322,21 +318,11 @@ public function getAvailableServerTypesProperty() ->values() ->toArray(); - ray('Filtered server types', [ - 'selected_location' => $this->selected_location, - 'filtered_count' => count($filtered), - ]); - return $filtered; } public function getAvailableImagesProperty() { - ray('Getting available images', [ - 'selected_server_type' => $this->selected_server_type, - 'total_images' => count($this->images), - 'images' => $this->images, - ]); if (! $this->selected_server_type) { return $this->images; @@ -344,10 +330,7 @@ public function getAvailableImagesProperty() $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); - ray('Server type data', $serverType); - if (! $serverType || ! isset($serverType['architecture'])) { - ray('No architecture in server type, returning all'); return $this->images; } @@ -359,11 +342,6 @@ public function getAvailableImagesProperty() ->values() ->toArray(); - ray('Filtered images', [ - 'architecture' => $architecture, - 'filtered_count' => count($filtered), - ]); - return $filtered; } @@ -386,8 +364,6 @@ public function getSelectedServerPriceProperty(): ?string public function updatedSelectedLocation($value) { - ray('Location selected', $value); - // Reset server type and image when location changes $this->selected_server_type = null; $this->selected_image = null; @@ -395,15 +371,13 @@ public function updatedSelectedLocation($value) public function updatedSelectedServerType($value) { - ray('Server type selected', $value); - // Reset image when server type changes $this->selected_image = null; } public function updatedSelectedImage($value) { - ray('Image selected', $value); + // } public function updatedSelectedCloudInitScriptId($value) @@ -433,18 +407,10 @@ private function createHetznerServer(string $token): array $publicKey = $privateKey->getPublicKey(); $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); - ray('Private Key Info', [ - 'private_key_id' => $this->private_key_id, - 'sha256_fingerprint' => $privateKey->fingerprint, - 'md5_fingerprint' => $md5Fingerprint, - ]); - // Check if SSH key already exists on Hetzner by comparing MD5 fingerprints $existingSshKeys = $hetznerService->getSshKeys(); $existingKey = null; - ray('Existing SSH Keys on Hetzner', $existingSshKeys); - foreach ($existingSshKeys as $key) { if ($key['fingerprint'] === $md5Fingerprint) { $existingKey = $key; @@ -455,12 +421,10 @@ private function createHetznerServer(string $token): array // Upload SSH key if it doesn't exist if ($existingKey) { $sshKeyId = $existingKey['id']; - ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]); } else { $sshKeyName = $privateKey->name; $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); $sshKeyId = $uploadedKey['id']; - ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); } // Normalize server name to lowercase for RFC 1123 compliance @@ -495,13 +459,9 @@ private function createHetznerServer(string $token): array $params['user_data'] = $this->cloud_init_script; } - ray('Server creation parameters', $params); - // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); - ray('Hetzner server created', $hetznerServer); - return $hetznerServer; } diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1f4cdf607..51c6a06ee 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -5,6 +5,7 @@ use App\Enums\ProxyTypes; use App\Models\Server; use App\Models\Team; +use App\Rules\ValidServerIp; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -55,8 +56,8 @@ protected function rules(): array 'new_private_key_value' => 'nullable|string', 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'ip' => 'required|string', - 'user' => 'required|string', + 'ip' => ['required', 'string', new ValidServerIp], + 'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', 'is_build_server' => 'required|boolean', ]; @@ -97,10 +98,13 @@ public function submit() $this->validate(); try { $this->authorize('create', Server::class); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) - ->exists()) { - return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.'); + $foundServer = Server::whereIp($this->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } if (is_null($this->private_key_id)) { diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index fd55717fa..810b95ed4 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -63,7 +63,8 @@ public function checkConnection() $this->dispatch('success', 'Server is reachable.'); $this->dispatch('refreshServerShow'); } else { - $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$sanitizedError); return; } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..c2d8205ef 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -6,6 +6,7 @@ use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Server; +use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -41,9 +42,13 @@ public function getListeners() ]; } - protected $rules = [ - 'generateExactLabels' => 'required|boolean', - ]; + protected function rules() + { + return [ + 'generateExactLabels' => 'required|boolean', + 'redirectUrl' => ['nullable', new SafeExternalUrl], + ]; + } public function mount() { @@ -51,6 +56,7 @@ public function mount() $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); + $this->loadProxyConfiguration(); } private function syncData(bool $toModel = false): void @@ -146,6 +152,7 @@ public function submit() { try { $this->authorize('update', $this->server); + $this->validate(); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index a21b0372b..3710064dc 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Models\Server; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -29,6 +30,11 @@ public function getListeners() public function startUnmanaged($id) { + if (! ValidationPatterns::isValidContainerName($id)) { + $this->dispatch('error', 'Invalid container identifier.'); + + return; + } $this->server->startUnmanaged($id); $this->dispatch('success', 'Container started.'); $this->loadUnmanagedContainers(); @@ -36,6 +42,11 @@ public function startUnmanaged($id) public function restartUnmanaged($id) { + if (! ValidationPatterns::isValidContainerName($id)) { + $this->dispatch('error', 'Invalid container identifier.'); + + return; + } $this->server->restartUnmanaged($id); $this->dispatch('success', 'Container restarted.'); $this->loadUnmanagedContainers(); @@ -43,6 +54,11 @@ public function restartUnmanaged($id) public function stopUnmanaged($id) { + if (! ValidationPatterns::isValidContainerName($id)) { + $this->dispatch('error', 'Invalid container identifier.'); + + return; + } $this->server->stopUnmanaged($id); $this->dispatch('success', 'Container stopped.'); $this->loadUnmanagedContainers(); diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index 310edcfe4..b4b99a3e7 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -31,7 +31,7 @@ public function mount(string $server_uuid) } } - public function toggleTerminal($password) + public function toggleTerminal($password, $selectedActions = []) { try { $this->authorize('update', $this->server); @@ -43,7 +43,7 @@ public function toggleTerminal($password) // Verify password if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } // Toggle the terminal setting @@ -55,6 +55,8 @@ public function toggleTerminal($password) $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; $this->dispatch('success', "Terminal access has been {$status}."); + + return true; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index cdcdc71fc..a4b35891b 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -19,19 +19,19 @@ class Sentinel extends Component public bool $isMetricsEnabled; - #[Validate(['required'])] + #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] public string $sentinelToken; public ?string $sentinelUpdatedAt = null; #[Validate(['required', 'integer', 'min:1'])] - public int $sentinelMetricsRefreshRateSeconds; + public int|string $sentinelMetricsRefreshRateSeconds; #[Validate(['required', 'integer', 'min:1'])] - public int $sentinelMetricsHistoryDays; + public int|string $sentinelMetricsHistoryDays; #[Validate(['required', 'integer', 'min:10'])] - public int $sentinelPushIntervalSeconds; + public int|string $sentinelPushIntervalSeconds; #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cc55da491..84cb65ee6 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -7,6 +7,7 @@ use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; +use App\Rules\ValidServerIp; use App\Services\HetznerService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -106,9 +107,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'ip' => 'required', - 'user' => 'required', - 'port' => 'required', + 'ip' => ['required', new ValidServerIp], + 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'port' => 'required|integer|between:1,65535', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', @@ -189,12 +190,16 @@ public function syncData(bool $toModel = false) $this->validate(); $this->authorize('update', $this->server); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) + $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->exists()) { + ->first(); + if ($foundServer) { $this->ip = $this->server->ip; - throw new \Exception('This IP/Domain is already in use by another server in your team.'); + if ($foundServer->team_id === currentTeam()->id) { + throw new \Exception('A server with this IP/Domain already exists in your team.'); + } + + throw new \Exception('A server with this IP/Domain is already in use by another team.'); } $this->server->name = $this->name; @@ -478,6 +483,22 @@ public function startHetznerServer() } } + public function refreshServerMetadata(): void + { + try { + $this->authorize('update', $this->server); + $result = $this->server->gatherServerMetadata(); + if ($result) { + $this->server->refresh(); + $this->dispatch('success', 'Server details refreshed.'); + } else { + $this->dispatch('error', 'Could not fetch server details. Is the server reachable?'); + } + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 1a5bd381b..59ca4cd36 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -89,7 +89,8 @@ 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.'
'; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; $this->server->update([ 'validation_logs' => $this->error, ]); @@ -198,6 +199,9 @@ public function validateDockerVersion() // Mark validation as complete $this->server->update(['is_validating' => false]); + // Auto-fetch server details now that validation passed + $this->server->gatherServerMetadata(); + $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); ServerValidated::dispatch($this->server->team_id, $this->server->uuid); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 16361ce79..d31f68859 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -3,6 +3,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; +use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_dns_validation_enabled; - #[Validate('nullable|string')] public ?string $custom_dns_servers = null; #[Validate('boolean')] @@ -43,7 +43,7 @@ public function rules() 'is_registration_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', - 'custom_dns_servers' => 'nullable|string', + 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], 'is_api_enabled' => 'boolean', 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', @@ -95,7 +95,9 @@ public function submit() // 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) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) { return $entry; } $invalidEntries[] = $entry; @@ -111,7 +113,7 @@ public function submit() $invalidEntries[] = $entry; return null; - })->filter()->unique(); + })->filter()->values()->all(); if (! empty($invalidEntries)) { $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); @@ -119,13 +121,15 @@ public function submit() return; } - if ($validEntries->isEmpty()) { + if (empty($validEntries)) { $this->dispatch('error', 'No valid IP addresses or subnets provided'); return; } - $this->allowed_ips = $validEntries->implode(','); + $validEntries = deduplicateAllowlist($validEntries); + + $this->allowed_ips = implode(',', $validEntries); } $this->instantSave(); @@ -153,6 +157,19 @@ public function instantSave() } } + public function toggleRegistration($password): bool + { + if (! verifyPasswordConfirmation($password, $this)) { + return false; + } + + $this->settings->is_registration_enabled = $this->is_registration_enabled = true; + $this->settings->save(); + $this->dispatch('success', 'Registration has been enabled.'); + + return true; + } + public function toggleTwoStepConfirmation($password): bool { if (! verifyPasswordConfirmation($password, $this)) { diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php new file mode 100644 index 000000000..1e54f1483 --- /dev/null +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -0,0 +1,321 @@ +executions = collect(); + $this->skipLogs = collect(); + $this->managerRuns = collect(); + } + + public function mount(): void + { + if (! isInstanceAdmin()) { + redirect()->route('dashboard'); + + return; + } + + $this->loadData(); + } + + public function updatedFilterType(): void + { + $this->skipPage = 0; + $this->loadData(); + } + + public function updatedFilterDate(): void + { + $this->skipPage = 0; + $this->loadData(); + } + + public function skipNextPage(): void + { + $this->skipPage += $this->skipDefaultTake; + $this->showSkipPrev = true; + $this->loadData(); + } + + public function skipPreviousPage(): void + { + $this->skipPage -= $this->skipDefaultTake; + if ($this->skipPage < 0) { + $this->skipPage = 0; + } + $this->showSkipPrev = $this->skipPage > 0; + $this->loadData(); + } + + public function refresh(): void + { + $this->loadData(); + } + + public function render() + { + return view('livewire.settings.scheduled-jobs', [ + 'executions' => $this->executions, + 'skipLogs' => $this->skipLogs, + 'managerRuns' => $this->managerRuns, + ]); + } + + private function loadData(?int $teamId = null): void + { + $this->executions = $this->getExecutions($teamId); + + $parser = new SchedulerLogParser; + $allSkips = $parser->getRecentSkips(500, $teamId); + $this->skipTotalCount = $allSkips->count(); + $this->skipLogs = $this->enrichSkipLogsWithLinks( + $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values() + ); + $this->showSkipPrev = $this->skipPage > 0; + $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; + $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; + $this->managerRuns = $parser->getRecentRuns(30, $teamId); + } + + private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection + { + $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values(); + $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values(); + $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values(); + + $tasks = $taskIds->isNotEmpty() + ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id') + : collect(); + + $backups = $backupIds->isNotEmpty() + ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id') + : collect(); + + $servers = $serverIds->isNotEmpty() + ? Server::whereIn('id', $serverIds)->get()->keyBy('id') + : collect(); + + return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array { + $skip['link'] = null; + $skip['resource_name'] = null; + + if ($skip['type'] === 'task') { + $task = $tasks->get($skip['context']['task_id'] ?? null); + if ($task) { + $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name; + $resource = $task->application ?? $task->service; + $environment = $resource?->environment; + $project = $environment?->project; + if ($project && $environment && $resource) { + $routeName = $task->application_id + ? 'project.application.scheduled-tasks' + : 'project.service.scheduled-tasks'; + $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid'; + $skip['link'] = route($routeName, [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + $routeKey => $resource->uuid, + 'task_uuid' => $task->uuid, + ]); + } + } + } elseif ($skip['type'] === 'backup') { + $backup = $backups->get($skip['context']['backup_id'] ?? null); + if ($backup) { + $database = $backup->database; + $skip['resource_name'] = $database?->name ?? 'Database backup'; + $environment = $database?->environment; + $project = $environment?->project; + if ($project && $environment && $database) { + $skip['link'] = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } + } + } elseif ($skip['type'] === 'docker_cleanup') { + $server = $servers->get($skip['context']['server_id'] ?? null); + if ($server) { + $skip['resource_name'] = $server->name; + $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]); + } + } + + return $skip; + }); + } + + private function getExecutions(?int $teamId = null): Collection + { + $dateFrom = $this->getDateFrom(); + + $backups = collect(); + $tasks = collect(); + $cleanups = collect(); + + if ($this->filterType === 'all' || $this->filterType === 'backup') { + $backups = $this->getBackupExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'task') { + $tasks = $this->getTaskExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'cleanup') { + $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); + } + + return $backups->concat($tasks)->concat($cleanups) + ->sortByDesc('created_at') + ->values() + ->take(100); + } + + private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $backup = $execution->scheduledDatabaseBackup; + $database = $backup?->database; + $server = $backup?->server(); + + return [ + 'id' => $execution->id, + 'type' => 'backup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $database?->name ?? 'Deleted database', + 'resource_type' => $database ? class_basename($database) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $backup?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->updated_at, + 'message' => $execution->message, + 'size' => $execution->size ?? null, + ]; + }); + } + + private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, function ($q) use ($teamId) { + $q->where(function ($sub) use ($teamId) { + $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) + ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); + }); + }) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $task = $execution->scheduledTask; + $resource = $task?->application ?? $task?->service; + $server = $task?->server(); + $teamId = $server?->team_id; + + return [ + 'id' => $execution->id, + 'type' => 'task', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $task?->name ?? 'Deleted task', + 'resource_type' => $resource ? class_basename($resource) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $teamId, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = DockerCleanupExecution::with(['server']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $server = $execution->server; + + return [ + 'id' => $execution->id, + 'type' => 'cleanup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $server?->name ?? 'Deleted server', + 'resource_type' => 'Server', + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $server?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at ?? $execution->updated_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getDateFrom(): ?Carbon + { + return match ($this->filterDate) { + 'last_24h' => now()->subDay(), + 'last_7d' => now()->subWeek(), + 'last_30d' => now()->subMonth(), + default => null, + }; + } +} diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 01a67c38c..a200ef689 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -25,6 +25,9 @@ class Updates extends Component public function mount() { + if (! isInstanceAdmin()) { + return redirect()->route('dashboard'); + } if (! isCloud()) { $this->server = Server::findOrFail(0); } diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 84f5c6081..5336c0c9a 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -6,6 +6,7 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -82,7 +83,8 @@ public function addCoolifyDatabase() $postgres_password = $envs['POSTGRES_PASSWORD']; $postgres_user = $envs['POSTGRES_USER']; $postgres_db = $envs['POSTGRES_DB']; - $this->database = StandalonePostgresql::create([ + $this->database = new StandalonePostgresql; + $this->database->forceFill([ 'id' => 0, 'name' => 'coolify-db', 'description' => 'Coolify database', @@ -90,16 +92,17 @@ public function addCoolifyDatabase() 'postgres_password' => $postgres_password, 'postgres_db' => $postgres_db, 'status' => 'running', - 'destination_type' => \App\Models\StandaloneDocker::class, + 'destination_type' => StandaloneDocker::class, 'destination_id' => 0, ]); + $this->database->save(); $this->backup = ScheduledDatabaseBackup::create([ 'id' => 0, 'enabled' => true, 'save_s3' => false, 'frequency' => '0 0 * * *', 'database_id' => $this->database->id, - 'database_type' => \App\Models\StandalonePostgresql::class, + 'database_type' => StandalonePostgresql::class, 'team_id' => currentTeam()->id, ]); $this->database->refresh(); diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index ca48e9b16..8c0e24400 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -33,7 +33,7 @@ class SettingsEmail extends Component public ?string $smtpHost = null; #[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])] - public ?int $smtpPort = null; + public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] public ?string $smtpEncryption = 'starttls'; @@ -45,7 +45,7 @@ class SettingsEmail extends Component public ?string $smtpPassword = null; #[Validate(['nullable', 'numeric'])] - public ?int $smtpTimeout = null; + public ?string $smtpTimeout = null; #[Validate(['boolean'])] public bool $resendEnabled = false; diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 0bdc1503f..bfbdf9212 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -40,6 +40,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'environment', 'team_id' => currentTeam()->id, ]); @@ -50,11 +51,14 @@ public function saveKey($data) } } - public function mount() + public function mount(?string $project_uuid = null, ?string $environment_uuid = null) { $this->parameters = get_route_parameters(); - $this->project = Project::ownedByCurrentTeam()->where('uuid', request()->route('project_uuid'))->firstOrFail(); - $this->environment = $this->project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); + $projectUuid = $project_uuid ?? request()->route('project_uuid'); + $environmentUuid = $environment_uuid ?? request()->route('environment_uuid'); + + $this->project = Project::ownedByCurrentTeam()->where('uuid', $projectUuid)->firstOrFail(); + $this->environment = $this->project->environments()->where('uuid', $environmentUuid)->firstOrFail(); $this->getDevView(); } @@ -138,7 +142,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->environment->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index b205ea1ec..c9f0dcd8e 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'project', 'team_id' => currentTeam()->id, ]); @@ -43,9 +44,9 @@ public function saveKey($data) } } - public function mount() + public function mount(?string $project_uuid = null) { - $projectUuid = request()->route('project_uuid'); + $projectUuid = $project_uuid ?? request()->route('project_uuid'); $teamId = currentTeam()->id; $project = Project::where('team_id', $teamId)->where('uuid', $projectUuid)->first(); if (! $project) { @@ -129,7 +130,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->project->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/SharedVariables/Server/Index.php b/app/Livewire/SharedVariables/Server/Index.php new file mode 100644 index 000000000..cd10e510a --- /dev/null +++ b/app/Livewire/SharedVariables/Server/Index.php @@ -0,0 +1,22 @@ +servers = Server::ownedByCurrentTeamCached(); + } + + public function render() + { + return view('livewire.shared-variables.server.index'); + } +} diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php new file mode 100644 index 000000000..a0498b2b7 --- /dev/null +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -0,0 +1,190 @@ + 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs']; + + public function saveKey($data) + { + try { + $this->authorize('update', $this->server); + + if (in_array($data['key'], ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])) { + throw new \Exception('Cannot create predefined variable.'); + } + + $found = $this->server->environment_variables()->where('key', $data['key'])->first(); + if ($found) { + throw new \Exception('Variable already exists.'); + } + $this->server->environment_variables()->create([ + 'key' => $data['key'], + 'value' => $data['value'], + 'is_multiline' => $data['is_multiline'], + 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, + 'type' => 'server', + 'team_id' => currentTeam()->id, + ]); + $this->server->refresh(); + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function mount(?string $server_uuid = null) + { + $serverUuid = $server_uuid ?? request()->route('server_uuid'); + $teamId = currentTeam()->id; + $server = Server::where('team_id', $teamId)->where('uuid', $serverUuid)->first(); + if (! $server) { + return redirect()->route('dashboard'); + } + $this->authorize('view', $server); + $this->server = $server; + $this->getDevView(); + } + + public function switch() + { + $this->authorize('view', $this->server); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } + + public function getDevView() + { + $this->variables = $this->formatEnvironmentVariables($this->server->environment_variables->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])->sortBy('key')); + } + + private function formatEnvironmentVariables($variables) + { + return $variables->map(function ($item) { + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } + + return "$item->key=$item->value"; + })->join("\n"); + } + + public function submit() + { + try { + $this->authorize('update', $this->server); + $this->handleBulkSubmit(); + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->refreshEnvs(); + } + } + + private function handleBulkSubmit() + { + $variables = parseEnvFormatToArray($this->variables); + + $changesMade = DB::transaction(function () use ($variables) { + // Delete removed variables + $deletedCount = $this->deleteRemovedVariables($variables); + + // Update or create variables + $updatedCount = $this->updateOrCreateVariables($variables); + + return $deletedCount > 0 || $updatedCount > 0; + }); + + if ($changesMade) { + $this->dispatch('success', 'Environment variables updated.'); + } + } + + private function deleteRemovedVariables($variables) + { + $variablesToDelete = $this->server->environment_variables() + ->whereNotIn('key', array_keys($variables)) + ->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME']) + ->get(); + + if ($variablesToDelete->isEmpty()) { + return 0; + } + + $this->server->environment_variables() + ->whereNotIn('key', array_keys($variables)) + ->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME']) + ->delete(); + + return $variablesToDelete->count(); + } + + private function updateOrCreateVariables($variables) + { + $count = 0; + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Skip predefined variables + if (in_array($key, ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])) { + continue; + } + $found = $this->server->environment_variables()->where('key', $key)->first(); + + if ($found) { + if (! $found->is_shown_once && ! $found->is_multiline) { + if ($found->value !== $value || $found->comment !== $comment) { + $found->value = $value; + $found->comment = $comment; + $found->save(); + $count++; + } + } + } else { + $this->server->environment_variables()->create([ + 'key' => $key, + 'value' => $value, + 'comment' => $comment, + 'is_multiline' => false, + 'is_literal' => false, + 'type' => 'server', + 'team_id' => currentTeam()->id, + ]); + $count++; + } + } + + return $count; + } + + public function refreshEnvs() + { + $this->server->refresh(); + $this->getDevView(); + } + + public function render() + { + return view('livewire.shared-variables.server.show'); + } +} diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index e420686f0..29e21a1b7 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'team', 'team_id' => currentTeam()->id, ]); @@ -128,7 +129,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->team->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 0a38e6088..d6537069c 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -5,6 +5,7 @@ use App\Jobs\GithubAppPermissionJob; use App\Models\GithubApp; use App\Models\PrivateKey; +use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Lcobucci\JWT\Configuration; @@ -71,24 +72,27 @@ class Change extends Component public $privateKeys; - protected $rules = [ - 'name' => 'required|string', - 'organization' => 'nullable|string', - 'apiUrl' => 'required|string', - 'htmlUrl' => 'required|string', - 'customUser' => 'required|string', - 'customPort' => 'required|int', - 'appId' => 'nullable|int', - 'installationId' => 'nullable|int', - 'clientId' => 'nullable|string', - 'clientSecret' => 'nullable|string', - 'webhookSecret' => 'nullable|string', - 'isSystemWide' => 'required|bool', - 'contents' => 'nullable|string', - 'metadata' => 'nullable|string', - 'pullRequests' => 'nullable|string', - 'privateKeyId' => 'nullable|int', - ]; + protected function rules(): array + { + return [ + 'name' => 'required|string', + 'organization' => 'nullable|string', + 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'appId' => 'nullable|int', + 'installationId' => 'nullable|int', + 'clientId' => 'nullable|string', + 'clientSecret' => 'nullable|string', + 'webhookSecret' => 'nullable|string', + 'isSystemWide' => 'required|bool', + 'contents' => 'nullable|string', + 'metadata' => 'nullable|string', + 'pullRequests' => 'nullable|string', + 'privateKeyId' => 'nullable|int', + ]; + } public function boot() { @@ -239,7 +243,7 @@ public function mount() if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->ipv4 ?? ''; + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 4ece6a92f..ec2ba3f08 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -3,6 +3,7 @@ namespace App\Livewire\Source\Github; use App\Models\GithubApp; +use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -37,8 +38,8 @@ public function createGitHubApp() $this->validate([ 'name' => 'required|string', 'organization' => 'nullable|string', - 'api_url' => 'required|string', - 'html_url' => 'required|string', + 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'required|string', 'custom_port' => 'required|int', 'is_system_wide' => 'required|bool', diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 4dc0b6ae2..791226334 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -6,6 +6,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\On; use Livewire\Component; class Form extends Component @@ -131,19 +132,7 @@ public function testConnection() } } - public function delete() - { - try { - $this->authorize('delete', $this->storage); - - $this->storage->delete(); - - return redirect()->route('storage.index'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - + #[On('submitStorage')] public function submit() { try { diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php new file mode 100644 index 000000000..643ecb3eb --- /dev/null +++ b/app/Livewire/Storage/Resources.php @@ -0,0 +1,85 @@ +storage->id) + ->where('save_s3', true) + ->get(); + + foreach ($backups as $backup) { + $this->selectedStorages[$backup->id] = $this->storage->id; + } + } + + public function disableS3(int $backupId): void + { + $backup = ScheduledDatabaseBackup::findOrFail($backupId); + + $backup->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + + unset($this->selectedStorages[$backupId]); + + $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + } + + public function moveBackup(int $backupId): void + { + $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $newStorageId = $this->selectedStorages[$backupId] ?? null; + + if (! $newStorageId || (int) $newStorageId === $this->storage->id) { + $this->dispatch('error', 'No change.', 'The backup is already using this storage.'); + + return; + } + + $newStorage = S3Storage::where('id', $newStorageId) + ->where('team_id', $this->storage->team_id) + ->first(); + + if (! $newStorage) { + $this->dispatch('error', 'Storage not found.'); + + return; + } + + $backup->update(['s3_storage_id' => $newStorage->id]); + + unset($this->selectedStorages[$backupId]); + + $this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}."); + } + + public function render() + { + $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + ->where('save_s3', true) + ->with('database') + ->get() + ->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id); + + $allStorages = S3Storage::where('team_id', $this->storage->team_id) + ->orderBy('name') + ->get(['id', 'name', 'is_usable']); + + return view('livewire.storage.resources', [ + 'groupedBackups' => $backups, + 'allStorages' => $allStorages, + ]); + } +} diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index fdf3d0d28..dc5121e94 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Models\ScheduledDatabaseBackup; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -12,6 +13,10 @@ class Show extends Component public $storage = null; + public string $currentRoute = ''; + + public int $backupCount = 0; + public function mount() { $this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first(); @@ -19,6 +24,21 @@ public function mount() abort(404); } $this->authorize('view', $this->storage); + $this->currentRoute = request()->route()->getName(); + $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count(); + } + + public function delete() + { + try { + $this->authorize('delete', $this->storage); + + $this->storage->delete(); + + return redirect()->route('storage.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 1388d3244..33eed3a6a 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -2,21 +2,214 @@ namespace App\Livewire\Subscription; +use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd; +use App\Actions\Stripe\RefundSubscription; +use App\Actions\Stripe\ResumeSubscription; +use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Team; +use Carbon\Carbon; +use Illuminate\Support\Facades\Hash; use Livewire\Component; +use Stripe\StripeClient; class Actions extends Component { public $server_limits = 0; - public function mount() + public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + public int $minServerLimit = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + public int $maxServerLimit = UpdateSubscriptionQuantity::MAX_SERVER_LIMIT; + + public ?array $pricePreview = null; + + public bool $isRefundEligible = false; + + public int $refundDaysRemaining = 0; + + public bool $refundCheckLoading = true; + + public bool $refundAlreadyUsed = false; + + public string $billingInterval = 'monthly'; + + public ?string $nextBillingDate = null; + + public function mount(): void { $this->server_limits = Team::serverLimit(); + $this->quantity = (int) $this->server_limits; + $this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly'; } - public function stripeCustomerPortal() + public function loadPricePreview(int $quantity): void + { + $this->quantity = $quantity; + $result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $quantity); + $this->pricePreview = $result['success'] ? $result['preview'] : null; + } + + // Password validation is intentionally skipped for quantity updates. + // Unlike refunds/cancellations, changing the server limit is a + // non-destructive, reversible billing adjustment (prorated by Stripe). + public function updateQuantity(string $password = ''): bool + { + if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) { + $this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.'); + $this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + return true; + } + + if ($this->quantity === (int) $this->server_limits) { + return true; + } + + $result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity); + + if ($result['success']) { + $this->server_limits = $this->quantity; + $this->pricePreview = null; + $this->dispatch('success', 'Server limit updated to '.$this->quantity.'.'); + + return true; + } + + $this->dispatch('error', $result['error'] ?? 'Failed to update server limit.'); + $this->quantity = (int) $this->server_limits; + + return true; + } + + public function loadRefundEligibility(): void + { + $this->checkRefundEligibility(); + $this->refundCheckLoading = false; + } + + public function stripeCustomerPortal(): void { $session = getStripeCustomerPortalSession(currentTeam()); redirect($session->url); } + + public function refundSubscription(string $password): bool|string + { + if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { + return 'Invalid password.'; + } + + $result = (new RefundSubscription)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription refunded successfully.'); + $this->redirect(route('subscription.index'), navigate: true); + + return true; + } + + $this->dispatch('error', 'Something went wrong with the refund. Please contact us.'); + + return true; + } + + public function cancelImmediately(string $password): bool|string + { + if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { + return 'Invalid password.'; + } + + $team = currentTeam(); + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); + + return true; + } + + try { + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe->subscriptions->cancel($subscription->stripe_subscription_id); + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'Cancelled immediately by user', + 'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(), + ]); + + $team->subscriptionEnded(); + + \Log::info("Subscription {$subscription->stripe_subscription_id} cancelled immediately for team {$team->name}"); + + $this->dispatch('success', 'Subscription cancelled successfully.'); + $this->redirect(route('subscription.index'), navigate: true); + + return true; + } catch (\Exception $e) { + \Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage()); + + $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); + + return true; + } + } + + public function cancelAtPeriodEnd(string $password): bool|string + { + if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { + return 'Invalid password.'; + } + + $result = (new CancelSubscriptionAtPeriodEnd)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription will be cancelled at the end of the billing period.'); + + return true; + } + + $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); + + return true; + } + + public function resumeSubscription(): bool + { + $result = (new ResumeSubscription)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription resumed successfully.'); + + return true; + } + + $this->dispatch('error', 'Something went wrong resuming the subscription. Please contact us.'); + + return true; + } + + private function checkRefundEligibility(): void + { + if (! isCloud() || ! currentTeam()->subscription?->stripe_subscription_id) { + return; + } + + try { + $this->refundAlreadyUsed = currentTeam()->subscription?->stripe_refunded_at !== null; + $result = (new RefundSubscription)->checkEligibility(currentTeam()); + $this->isRefundEligible = $result['eligible']; + $this->refundDaysRemaining = $result['days_remaining']; + + if ($result['current_period_end']) { + $this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y'); + } + } catch (\Exception $e) { + \Log::warning('Refund eligibility check failed: '.$e->getMessage()); + } + } } diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 6b2d3fb36..6e1b85404 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -11,6 +11,12 @@ class PricingPlans extends Component { public function subscribeStripe($type) { + if (currentTeam()->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return; + } + Stripe::setApiKey(config('subscription.stripe_api_key')); $priceId = match ($type) { diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c8d44d42b..09878f27b 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -49,14 +49,14 @@ public function getUsers() } } - public function delete($id, $password) + public function delete($id, $password, $selectedActions = []) { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); } if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if (! auth()->user()->isInstanceAdmin()) { @@ -71,6 +71,8 @@ public function delete($id, $password) try { $user->delete(); $this->getUsers(); + + return true; } catch (\Exception $e) { return $this->dispatch('error', $e->getMessage()); } diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index 7948ad6a9..1b8701d94 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -23,24 +23,42 @@ class Upgrade extends Component public function mount() { - $this->currentVersion = config('constants.coolify.version'); - $this->devMode = isDev(); + $this->refreshUpgradeState(); } public function checkUpdate() { try { - $this->latestVersion = get_latest_version_of_coolify(); - $this->currentVersion = config('constants.coolify.version'); - $this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false); - if (isDev()) { - $this->isUpgradeAvailable = true; - } + $this->refreshUpgradeState(); } catch (\Throwable $e) { return handleError($e, $this); } } + protected function refreshUpgradeState(): void + { + $this->currentVersion = config('constants.coolify.version'); + $this->latestVersion = get_latest_version_of_coolify(); + $this->devMode = isDev(); + + if ($this->devMode) { + $this->isUpgradeAvailable = true; + + return; + } + + $settings = InstanceSettings::find(0); + $hasNewerVersion = version_compare($this->latestVersion, $this->currentVersion, '>'); + $newVersionAvailable = (bool) data_get($settings, 'new_version_available', false); + + if ($settings && $newVersionAvailable && ! $hasNewerVersion) { + $settings->update(['new_version_available' => false]); + $newVersionAvailable = false; + } + + $this->isUpgradeAvailable = $hasNewerVersion && $newVersionAvailable; + } + public function upgrade() { try { diff --git a/app/Models/Application.php b/app/Models/Application.php index d6c222a97..fef6f6e4c 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -61,6 +61,8 @@ 'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'], 'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'], 'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'], + 'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']], + 'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'], @@ -116,7 +118,100 @@ class Application extends BaseModel private static $parserVersion = '5'; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'fqdn', + 'git_repository', + 'git_branch', + 'git_commit_sha', + 'git_full_url', + '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', + 'health_check_type', + 'health_check_command', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'status', + 'preview_url_template', + 'dockerfile', + 'dockerfile_location', + 'dockerfile_target_build', + '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', + 'docker_compose_location', + 'docker_compose_pr_location', + 'docker_compose', + 'docker_compose_pr', + 'docker_compose_raw', + 'docker_compose_pr_raw', + 'docker_compose_domains', + 'docker_compose_custom_start_command', + 'docker_compose_custom_build_command', + 'swarm_replicas', + 'swarm_placement_constraints', + 'watch_paths', + 'redirect', + 'compose_parsing_version', + 'custom_nginx_configuration', + 'custom_network_aliases', + 'custom_healthcheck_found', + 'nixpkgsarchive', + 'is_http_basic_auth_enabled', + 'http_basic_auth_username', + 'http_basic_auth_password', + 'connect_to_docker_network', + 'force_domain_override', + 'is_container_label_escape_enabled', + 'use_build_server', + 'config_hash', + 'last_online_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'uuid', + 'environment_id', + 'destination_id', + 'destination_type', + 'source_id', + 'source_type', + 'repository_project_id', + 'private_key_id', + ]; protected $appends = ['server_status']; @@ -175,7 +270,7 @@ protected static function booted() } } if (count($payload) > 0) { - $application->forceFill($payload); + $application->fill($payload); } // Buildpack switching cleanup logic @@ -388,7 +483,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } } @@ -987,17 +1082,24 @@ public function isPRDeployable(): bool public function deploymentType() { - if (isDev() && data_get($this, 'private_key_id') === 0) { + $privateKeyId = data_get($this, 'private_key_id'); + + // Real private key (id > 0) always takes precedence + if ($privateKeyId !== null && $privateKeyId > 0) { return 'deploy_key'; } - if (data_get($this, 'private_key_id')) { - return 'deploy_key'; - } elseif (data_get($this, 'source')) { + + // GitHub/GitLab App source + if (data_get($this, 'source')) { return 'source'; - } else { - return 'other'; } - throw new \Exception('No deployment type found'); + + // Localhost key (id = 0) when no source is configured + if ($privateKeyId === 0) { + return 'deploy_key'; + } + + return 'other'; } public function could_set_build_commands(): bool @@ -1042,7 +1144,7 @@ public function isLogDrainEnabled() 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->custom_network_aliases.$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->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build); + $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->custom_network_aliases.$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->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { @@ -1085,19 +1187,28 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; - if ($this->git_commit_sha !== 'HEAD') { + // Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided, + // so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone. + $sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; + + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. + // Invalid refs will cause the git checkout/fetch command to fail on the remote server. + $commitToUse = $commit ?? $this->git_commit_sha; + + if ($commitToUse !== 'HEAD') { + $escapedCommit = escapeshellarg($commitToUse); // 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 {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1108,10 +1219,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // 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"; + $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull"; } return $git_clone_command; @@ -1127,7 +1238,7 @@ public function getGitRemoteStatus(string $deployment_uuid) 'is_accessible' => true, 'error' => null, ]; - } catch (\RuntimeException $ex) { + } catch (RuntimeException $ex) { return [ 'is_accessible' => false, 'error' => $ex->getMessage(), @@ -1156,14 +1267,15 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $base_command = "{$base_command} {$escapedRepoUrl}"; } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; @@ -1182,6 +1294,62 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + + if ($exec_in_docker) { + $commands = collect([ + executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $base_command = "{$base_command} {$escapedCustomRepository}"; + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { @@ -1279,13 +1447,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $source_html_url_host = $url['host']; $source_html_url_scheme = $url['scheme']; - if ($this->source->getMorphClass() === \App\Models\GithubApp::class) { + if ($this->source->getMorphClass() === GithubApp::class) { if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $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); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1294,19 +1462,20 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$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); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1332,6 +1501,78 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = escapeshellarg($customRepository); + $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + if ($only_checkout) { + $git_clone_command = $git_clone_command_base; + } else { + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand); + } + if ($exec_in_docker) { + $commands = collect([ + executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($pull_request_id !== 0) { + $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + } else { + $commands->push("echo 'Checking out $branch'"); + } + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { $fullRepoUrl = $customRepository; @@ -1341,11 +1582,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $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}"; + $deployKeySshCommand = "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_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1403,7 +1645,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -1451,7 +1693,7 @@ public function oldRawParser() try { $yaml = Yaml::parse($this->docker_compose_raw); } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + throw new RuntimeException($e->getMessage()); } $services = data_get($yaml, 'services'); @@ -1533,7 +1775,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $fileList = collect([".$workdir$composeFile"]); $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { - throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}"); + throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); } $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); $gitVersion = str($getGitVersion)->explode(' ')->last(); @@ -1583,15 +1825,15 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->save(); if (str($e->getMessage())->contains('No such file')) { - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) { if ($this->deploymentType() === 'deploy_key') { - throw new \RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.'); + throw new RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.'); } - throw new \RuntimeException('Repository does not exist. Please check your repository URL and try again.'); + throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } - throw new \RuntimeException($e->getMessage()); + throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block $commands = collect([ @@ -1644,7 +1886,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->base_directory = $initialBaseDirectory; $this->save(); - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } } @@ -1679,7 +1921,8 @@ public function fqdns(): Attribute protected function buildGitCheckoutCommand($target): string { - $command = "git checkout $target"; + $escapedTarget = escapeshellarg($target); + $command = "git checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { $command .= ' && git submodule update --init --recursive'; @@ -1959,17 +2202,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false } } - public static function getDomainsByUuid(string $uuid): array - { - $application = self::where('uuid', $uuid)->first(); - - if ($application) { - return $application->fqdns; - } - - return []; - } - public function getLimits(): array { return [ diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 34257e7a7..67f28523c 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -16,6 +16,7 @@ 'application_id' => ['type' => 'string'], 'deployment_uuid' => ['type' => 'string'], 'pull_request_id' => ['type' => 'integer'], + 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true], 'force_rebuild' => ['type' => 'boolean'], 'commit' => ['type' => 'string'], 'status' => ['type' => 'string'], @@ -39,9 +40,36 @@ )] class ApplicationDeploymentQueue extends Model { - protected $guarded = []; + protected $fillable = [ + 'application_id', + 'deployment_uuid', + 'pull_request_id', + 'docker_registry_image_tag', + 'force_rebuild', + 'commit', + 'status', + 'is_webhook', + 'logs', + 'current_process_id', + 'restart_only', + 'git_type', + 'server_id', + 'application_name', + 'server_name', + 'deployment_url', + 'destination_id', + 'only_this_server', + 'rollback', + 'commit_message', + 'is_api', + 'build_server_id', + 'horizon_job_id', + 'horizon_job_worker', + 'finished_at', + ]; protected $casts = [ + 'pull_request_id' => 'integer', 'finished_at' => 'datetime', ]; diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 7373fdb16..f08a48cea 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -10,7 +10,23 @@ class ApplicationPreview extends BaseModel { use SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'application_id', + 'pull_request_id', + 'pull_request_html_url', + 'pull_request_issue_comment_id', + 'fqdn', + 'status', + 'git_type', + 'docker_compose_domains', + 'docker_registry_image_tag', + 'last_online_at', + ]; + + protected $casts = [ + 'pull_request_id' => 'integer', + ]; protected static function booted() { @@ -37,7 +53,7 @@ protected static function booted() $persistentStorages = $preview->persistentStorages()->get() ?? collect(); if ($persistentStorages->count() > 0) { foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } } @@ -47,7 +63,7 @@ protected static function booted() }); static::saving(function ($preview) { if ($preview->isDirty('status')) { - $preview->forceFill(['last_online_at' => now()]); + $preview->last_online_at = now(); } }); } @@ -69,7 +85,7 @@ public function application() public function persistentStorages() { - return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource'); + return $this->morphMany(LocalPersistentVolume::class, 'resource'); } public function generate_preview_fqdn() @@ -166,6 +182,16 @@ public function generate_preview_fqdn_compose() } $this->docker_compose_domains = json_encode($docker_compose_domains); + + // Populate fqdn from generated domains so webhook notifications can read it + $allDomains = collect($docker_compose_domains) + ->pluck('domain') + ->filter(fn ($d) => ! empty($d)) + ->flatMap(fn ($d) => explode(',', $d)) + ->implode(','); + + $this->fqdn = ! empty($allDomains) ? $allDomains : null; + $this->save(); } } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index f40977b3e..731a9b5da 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -28,7 +28,43 @@ class ApplicationSetting extends Model 'docker_images_to_keep' => 'integer', ]; - protected $guarded = []; + protected $fillable = [ + 'application_id', + 'is_static', + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_auto_deploy_enabled', + 'is_force_https_enabled', + 'is_debug_enabled', + 'is_preview_deployments_enabled', + 'is_log_drain_enabled', + 'is_gpu_enabled', + 'gpu_driver', + 'gpu_count', + 'gpu_device_ids', + 'gpu_options', + 'is_include_timestamps', + 'is_swarm_only_worker_nodes', + 'is_raw_compose_deployment_enabled', + 'is_build_server_enabled', + 'is_consistent_container_name_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'connect_to_docker_network', + 'custom_internal_name', + 'is_container_label_escape_enabled', + 'is_env_sorting_enabled', + 'is_container_label_readonly_enabled', + 'is_preserve_repository_enabled', + 'disable_build_cache', + 'is_spa', + 'is_git_shallow_clone_enabled', + 'is_pr_deployments_public_enabled', + 'use_build_secrets', + 'inject_build_args_to_dockerfile', + 'include_source_commit_in_build', + 'docker_images_to_keep', + ]; public function isStatic(): Attribute { diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 700ab0992..026d11fba 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -4,7 +4,12 @@ class CloudProviderToken extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'team_id', + 'provider', + 'token', + 'name', + ]; protected $casts = [ 'token' => 'encrypted', diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 23e1f0f12..e86598126 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -24,7 +24,8 @@ class DiscordNotificationSettings extends Model 'backup_failure_discord_notifications', 'scheduled_task_success_discord_notifications', 'scheduled_task_failure_discord_notifications', - 'docker_cleanup_discord_notifications', + 'docker_cleanup_success_discord_notifications', + 'docker_cleanup_failure_discord_notifications', 'server_disk_usage_discord_notifications', 'server_reachable_discord_notifications', 'server_unreachable_discord_notifications', diff --git a/app/Models/DockerCleanupExecution.php b/app/Models/DockerCleanupExecution.php index 405037e30..280277951 100644 --- a/app/Models/DockerCleanupExecution.php +++ b/app/Models/DockerCleanupExecution.php @@ -6,7 +6,13 @@ class DockerCleanupExecution extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'server_id', + 'status', + 'message', + 'cleanup_log', + 'finished_at', + ]; public function server(): BelongsTo { diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index ee31a49b6..1277e45d9 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -34,7 +34,11 @@ class EmailNotificationSettings extends Model 'backup_failure_email_notifications', 'scheduled_task_success_email_notifications', 'scheduled_task_failure_email_notifications', + 'docker_cleanup_success_email_notifications', + 'docker_cleanup_failure_email_notifications', 'server_disk_usage_email_notifications', + 'server_reachable_email_notifications', + 'server_unreachable_email_notifications', 'server_patch_email_notifications', 'traefik_outdated_email_notifications', ]; diff --git a/app/Models/Environment.php b/app/Models/Environment.php index c2ad9d2cb..55830f889 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -4,6 +4,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; #[OA\Schema( @@ -21,9 +22,15 @@ class Environment extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'project_id', + 'uuid', + ]; protected static function booted() { @@ -56,7 +63,7 @@ public function isEmpty() public function environment_variables() { - return $this->hasMany(SharedEnvironmentVariable::class); + return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'environment'); } public function applications() diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 4f1e277e4..83212267c 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -24,6 +24,7 @@ 'key' => ['type' => 'string'], 'value' => ['type' => 'string'], 'real_value' => ['type' => 'string'], + 'comment' => ['type' => 'string', 'nullable' => true], 'version' => ['type' => 'string'], 'created_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'], @@ -31,7 +32,35 @@ )] class EnvironmentVariable extends BaseModel { - protected $guarded = []; + protected $attributes = [ + 'is_runtime' => true, + 'is_buildtime' => true, + ]; + + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Polymorphic relationship + 'resourceable_type', + 'resourceable_id', + + // Boolean flags + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_shared', + 'is_required', + + // Metadata + 'version', + 'order', + ]; protected $casts = [ 'key' => 'string', @@ -67,6 +96,7 @@ protected static function booted() 'is_literal' => $environment_variable->is_literal ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false, + 'comment' => $environment_variable->comment, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, @@ -122,6 +152,17 @@ public function realValue(): Attribute return null; } + // Load relationships needed for shared variable resolution + if (! $resource->relationLoaded('environment')) { + $resource->load('environment'); + } + if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) { + $resource->load('server'); + } + if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) { + $resource->load('destination.server'); + } + $real_value = $this->get_real_environment_variables($this->value, $resource); // Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160) @@ -187,10 +228,57 @@ protected function isShared(): Attribute ); } + public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null) + { + return $this->get_real_environment_variables_internal($environment_variable, $resource, $server); + } + + public function getResolvedValueWithServer($server = null) + { + if (! $this->relationLoaded('resourceable')) { + $this->load('resourceable'); + } + $resource = $this->resourceable; + if (! $resource) { + return null; + } + + // Load relationships needed for shared variable resolution + if (! $resource->relationLoaded('environment')) { + $resource->load('environment'); + } + if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) { + $resource->load('server'); + } + if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) { + $resource->load('destination.server'); + } + + $real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server); + + // Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160) + if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) { + return $real_value; + } + + if ($this->is_literal || $this->is_multiline) { + $real_value = '\''.$real_value.'\''; + } else { + $real_value = escapeEnvVariables($real_value); + } + + return $real_value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { - if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) { - return null; + return $this->get_real_environment_variables_internal($environment_variable, $resource); + } + + private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null) + { + if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) { + return $environment_variable; } $environment_variable = trim($environment_variable); $sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/'); @@ -203,19 +291,32 @@ private function get_real_environment_variables(?string $environment_variable = continue; } $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); + $id = null; if ($type->value() === 'environment') { $id = $resource->environment->id; } elseif ($type->value() === 'project') { $id = $resource->environment->project->id; } elseif ($type->value() === 'team') { $id = $resource->team()->id; + } elseif ($type->value() === 'server') { + if ($serverOverride) { + $id = $serverOverride->id; + } elseif (isset($resource->server) && $resource->server) { + $id = $resource->server->id; + } elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) { + $id = $resource->destination->server->id; + } } if (is_null($id)) { continue; } - $environment_variable_found = SharedEnvironmentVariable::where('type', $type)->where('key', $variable)->where('team_id', $resource->team()->id)->where("{$type}_id", $id)->first(); - if ($environment_variable_found) { - $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $environment_variable_found->value); + $found = SharedEnvironmentVariable::where('type', $type) + ->where('key', $variable) + ->where('team_id', $resource->team()->id) + ->where("{$type}_id", $id) + ->first(); + if ($found) { + $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value); } } diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index ab82c9a9c..54bbb3f7d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -6,7 +6,27 @@ class GithubApp extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'team_id', + 'private_key_id', + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_user', + 'custom_port', + 'app_id', + 'installation_id', + 'client_id', + 'client_secret', + 'webhook_secret', + 'is_system_wide', + 'is_public', + 'contents', + 'metadata', + 'pull_requests', + 'administration', + ]; protected $appends = ['type']; @@ -92,7 +112,7 @@ public function type(): Attribute { return Attribute::make( get: function () { - if ($this->getMorphClass() === \App\Models\GithubApp::class) { + if ($this->getMorphClass() === GithubApp::class) { return 'github'; } }, diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 2112a4a66..06df8fd8d 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -4,6 +4,24 @@ class GitlabApp extends BaseModel { + protected $fillable = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_port', + 'custom_user', + 'is_system_wide', + 'is_public', + 'app_id', + 'app_secret', + 'oauth_id', + 'group_name', + 'public_key', + 'webhook_token', + 'deploy_key_id', + ]; + protected $hidden = [ 'webhook_token', 'app_secret', diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index ccc361d67..6061bc863 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -9,7 +9,43 @@ class InstanceSettings extends Model { - protected $guarded = []; + protected $fillable = [ + 'public_ipv4', + 'public_ipv6', + 'fqdn', + 'public_port_min', + 'public_port_max', + 'do_not_track', + 'is_auto_update_enabled', + 'is_registration_enabled', + 'next_channel', + 'smtp_enabled', + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_port', + 'smtp_encryption', + 'smtp_username', + 'smtp_password', + 'smtp_timeout', + 'resend_enabled', + 'resend_api_key', + 'is_dns_validation_enabled', + 'custom_dns_servers', + 'instance_name', + 'is_api_enabled', + 'allowed_ips', + 'auto_update_frequency', + 'update_check_frequency', + 'new_version_available', + 'instance_timezone', + 'helper_version', + 'disable_two_step_confirmation', + 'is_sponsorship_popup_enabled', + 'dev_helper_version', + 'is_wire_navigate_enabled', + ]; protected $casts = [ 'smtp_enabled' => 'boolean', diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 9d7095cb5..4b5c602c2 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Events\FileStorageChanged; +use App\Jobs\ServerStorageSaveJob; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Symfony\Component\Yaml\Yaml; @@ -14,11 +15,23 @@ class LocalFileVolume extends BaseModel // 'mount_path' => 'encrypted', 'content' => 'encrypted', 'is_directory' => 'boolean', + 'is_preview_suffix_enabled' => 'boolean', ]; use HasFactory; - protected $guarded = []; + protected $fillable = [ + 'fs_path', + 'mount_path', + 'content', + 'resource_type', + 'resource_id', + 'is_directory', + 'chown', + 'chmod', + 'is_based_on_git', + 'is_preview_suffix_enabled', + ]; public $appends = ['is_binary']; @@ -26,7 +39,7 @@ protected static function booted() { static::created(function (LocalFileVolume $fileVolume) { $fileVolume->load(['service']); - dispatch(new \App\Jobs\ServerStorageSaveJob($fileVolume)); + dispatch(new ServerStorageSaveJob($fileVolume)); }); } @@ -128,15 +141,22 @@ public function saveStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); + + // Validate fs_path early before any shell interpolation + validateShellSafePath($this->fs_path, 'storage path'); + $escapedFsPath = escapeshellarg($this->fs_path); + $escapedWorkdir = escapeshellarg($workdir); + if ($this->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"); + $commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true"); + $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true"); + $commands->push("cd {$escapedWorkdir}"); } if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { $parent_dir = str($this->fs_path)->beforeLast('/'); if ($parent_dir != '') { - $commands->push("mkdir -p $parent_dir > /dev/null 2>&1 || true"); + $escapedParentDir = escapeshellarg($parent_dir); + $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); } } $path = data_get_str($this, 'fs_path'); @@ -146,7 +166,7 @@ public function saveStorageOnServer() $path = $workdir.$path; } - // Validate and escape path to prevent command injection + // Validate and escape resolved path (may differ from fs_path if relative) validateShellSafePath($path, 'storage path'); $escapedPath = escapeshellarg($path); diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 7126253ea..2f0f482b0 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -3,12 +3,23 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Model; use Symfony\Component\Yaml\Yaml; -class LocalPersistentVolume extends Model +class LocalPersistentVolume extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'name', + 'mount_path', + 'host_path', + 'container_id', + 'resource_type', + 'resource_id', + 'is_preview_suffix_enabled', + ]; + + protected $casts = [ + 'is_preview_suffix_enabled' => 'boolean', + ]; public function resource() { diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index bb76d5ed6..1521678f3 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -5,6 +5,7 @@ use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; @@ -65,6 +66,20 @@ protected static function booted() } }); + static::saved(function ($key) { + if ($key->wasChanged('private_key')) { + try { + $key->storeInFileSystem(); + refresh_server_connection($key); + } catch (\Exception $e) { + Log::error('Failed to resync SSH key after update', [ + 'key_uuid' => $key->uuid, + 'error' => $e->getMessage(), + ]); + } + } + }); + static::deleted(function ($key) { self::deleteFromStorage($key); }); @@ -185,29 +200,54 @@ public function storeInFileSystem() { $filename = "ssh_key@{$this->uuid}"; $disk = Storage::disk('ssh-keys'); + $keyLocation = $this->getKeyLocation(); + $lockFile = $keyLocation.'.lock'; // 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()}"); + // Use file locking to prevent concurrent writes from corrupting the key + $lockHandle = fopen($lockFile, 'c'); + if ($lockHandle === false) { + throw new \Exception("Failed to open lock file for SSH key: {$lockFile}"); } - // Verify the file was actually created and has content - if (! $disk->exists($filename)) { - throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}"); - } + try { + if (! flock($lockHandle, LOCK_EX)) { + throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}"); + } - $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()}"); - } + // Attempt to store the private key + $success = $disk->put($filename, $this->private_key); - return $this->getKeyLocation(); + if (! $success) { + throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}"); + } + + // Verify the file was actually created and has content + if (! $disk->exists($filename)) { + throw new \Exception("SSH key file was not created: {$keyLocation}"); + } + + $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: {$keyLocation}"); + } + + // Ensure correct permissions for SSH (0600 required) + if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) { + Log::warning('Failed to set SSH key file permissions to 0600', [ + 'key_uuid' => $this->uuid, + 'path' => $keyLocation, + ]); + } + + return $keyLocation; + } finally { + flock($lockHandle, LOCK_UN); + fclose($lockHandle); + } } public static function deleteFromStorage(self $privateKey) @@ -237,7 +277,7 @@ protected function ensureStorageDirectoryExists() $testSuccess = $disk->put($testFilename, 'test'); if (! $testSuccess) { - throw new \Exception('SSH keys storage directory is not writable'); + throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify'); } // Clean up test file @@ -254,12 +294,6 @@ public function updatePrivateKey(array $data) return DB::transaction(function () use ($data) { $this->update($data); - try { - $this->storeInFileSystem(); - } catch (\Exception $e) { - throw new \Exception('Failed to update SSH key: '.$e->getMessage()); - } - return $this; }); } diff --git a/app/Models/Project.php b/app/Models/Project.php index 181951f14..632787a07 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -4,6 +4,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -20,9 +21,15 @@ class Project extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'team_id', + 'uuid', + ]; /** * Get query builder for projects owned by current team. @@ -67,7 +74,7 @@ protected static function booted() public function environment_variables() { - return $this->hasMany(SharedEnvironmentVariable::class); + return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'project'); } public function environments() diff --git a/app/Models/ProjectSetting.php b/app/Models/ProjectSetting.php index d93bea05b..8b59ffac6 100644 --- a/app/Models/ProjectSetting.php +++ b/app/Models/ProjectSetting.php @@ -6,7 +6,9 @@ class ProjectSetting extends Model { - protected $guarded = []; + protected $fillable = [ + 'project_id', + ]; public function project() { diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 189d05dd4..5ad617ad6 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -25,7 +25,8 @@ class PushoverNotificationSettings extends Model 'backup_failure_pushover_notifications', 'scheduled_task_success_pushover_notifications', 'scheduled_task_failure_pushover_notifications', - 'docker_cleanup_pushover_notifications', + 'docker_cleanup_success_pushover_notifications', + 'docker_cleanup_failure_pushover_notifications', 'server_disk_usage_pushover_notifications', 'server_reachable_pushover_notifications', 'server_unreachable_pushover_notifications', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3aae55966..d6feccc7e 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -12,7 +12,17 @@ class S3Storage extends BaseModel { use HasFactory, HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'region', + 'key', + 'secret', + 'bucket', + 'endpoint', + 'is_usable', + 'unusable_email_sent', + ]; protected $casts = [ 'is_usable' => 'boolean', @@ -40,6 +50,13 @@ protected static function boot(): void $storage->secret = trim($storage->secret); } }); + + static::deleting(function (S3Storage $storage) { + ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); + }); } public static function ownedByCurrentTeam(array $select = ['*']) @@ -59,6 +76,11 @@ public function team() return $this->belongsTo(Team::class); } + public function scheduledBackups() + { + return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id'); + } + public function awsUrl() { return "{$this->endpoint}/{$this->bucket}"; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 3ade21df8..4038c6288 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -8,7 +8,35 @@ class ScheduledDatabaseBackup extends BaseModel { - protected $guarded = []; + protected function casts(): array + { + return [ + 'database_backup_retention_max_storage_locally' => 'float', + 'database_backup_retention_max_storage_s3' => 'float', + ]; + } + + protected $fillable = [ + 'uuid', + 'team_id', + 'description', + 'enabled', + 'save_s3', + 'frequency', + 'database_backup_retention_amount_locally', + 'database_type', + 'database_id', + 's3_storage_id', + 'databases_to_backup', + 'dump_all', + 'database_backup_retention_days_locally', + 'database_backup_retention_max_storage_locally', + 'database_backup_retention_amount_s3', + 'database_backup_retention_days_s3', + 'database_backup_retention_max_storage_s3', + 'timeout', + 'disable_local_backup', + ]; public static function ownedByCurrentTeam() { diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index c0298ecc8..51ad46de9 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -6,7 +6,19 @@ class ScheduledDatabaseBackupExecution extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'scheduled_database_backup_id', + 'status', + 'message', + 'size', + 'filename', + 'database_name', + 'finished_at', + 'local_storage_deleted', + 's3_storage_deleted', + 's3_uploaded', + ]; protected function casts(): array { diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index bada0b7a5..40f8e1860 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -3,14 +3,49 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task model', + type: 'object', + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'], + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'], + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'], + ], +)] class ScheduledTask extends BaseModel { + use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'enabled', + 'name', + 'command', + 'frequency', + 'container', + 'timeout', + 'team_id', + 'application_id', + 'service_id', + ]; + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::where('team_id', $teamId)->orderBy('created_at', 'desc'); + } protected function casts(): array { diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 02fd6917a..1e26c7be3 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -3,10 +3,35 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task Execution model', + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'], + 'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'], + 'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'], + 'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'], + 'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'], + 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'], + 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'], + ], +)] class ScheduledTaskExecution extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'scheduled_task_id', + 'status', + 'message', + 'finished_at', + 'started_at', + 'retry_count', + 'duration', + 'error_details', + ]; protected function casts(): array { diff --git a/app/Models/Server.php b/app/Models/Server.php index d693aea6d..06426f211 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -11,7 +11,9 @@ use App\Events\ServerReachabilityChanged; use App\Helpers\SslHelper; use App\Jobs\CheckAndStartSentinelJob; +use App\Jobs\CheckTraefikVersionForServerJob; use App\Jobs\RegenerateSslCertJob; +use App\Livewire\Server\Proxy; use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; @@ -25,12 +27,14 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Stringable; use OpenApi\Attributes as OA; use Spatie\SchemalessAttributes\Casts\SchemalessAttributes; use Spatie\SchemalessAttributes\SchemalessAttributesTrait; use Spatie\Url\Url; +use Stevebauman\Purify\Facades\Purify; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; @@ -76,8 +80,8 @@ * - Traefik image uses the 'latest' tag (no fixed version tracking) * - No Traefik version detected on the server * - * @see \App\Jobs\CheckTraefikVersionForServerJob Where this data is populated - * @see \App\Livewire\Server\Proxy Where this data is read and displayed + * @see CheckTraefikVersionForServerJob Where this data is populated + * @see Proxy Where this data is read and displayed */ #[OA\Schema( description: 'Server model', @@ -131,10 +135,10 @@ protected static function booted() $payload['ip_previous'] = $server->getOriginal('ip'); } } - $server->forceFill($payload); + $server->fill($payload); }); static::saved(function ($server) { - if ($server->privateKey?->isDirty()) { + if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) { refresh_server_connection($server->privateKey); } }); @@ -144,19 +148,14 @@ protected static function booted() ]); if ($server->id === 0) { if ($server->isSwarm()) { - SwarmDocker::create([ + (new SwarmDocker)->forceFill([ 'id' => 0, 'name' => 'coolify', 'network' => 'coolify-overlay', 'server_id' => $server->id, - ]); + ])->save(); } else { - StandaloneDocker::create([ - 'id' => 0, - 'name' => 'coolify', - 'network' => 'coolify', - 'server_id' => $server->id, - ]); + (new StandaloneDocker)->forceFill($server->defaultStandaloneDockerAttributes(id: 0))->saveQuietly(); } } else { if ($server->isSwarm()) { @@ -166,18 +165,32 @@ protected static function booted() 'server_id' => $server->id, ]); } else { - $standaloneDocker = new StandaloneDocker([ - 'name' => 'coolify', - 'uuid' => (string) new Cuid2, - 'network' => 'coolify', - 'server_id' => $server->id, - ]); + $standaloneDocker = new StandaloneDocker; + $standaloneDocker->forceFill($server->defaultStandaloneDockerAttributes()); $standaloneDocker->saveQuietly(); } } if (! isset($server->proxy->redirect_enabled)) { $server->proxy->redirect_enabled = true; } + + // Create predefined server shared variables + SharedEnvironmentVariable::create([ + 'key' => 'COOLIFY_SERVER_UUID', + 'value' => $server->uuid, + 'type' => 'server', + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'is_literal' => true, + ]); + SharedEnvironmentVariable::create([ + 'key' => 'COOLIFY_SERVER_NAME', + 'value' => $server->name, + 'type' => 'server', + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'is_literal' => true, + ]); }); static::retrieved(function ($server) { if (! isset($server->proxy->redirect_enabled)) { @@ -231,6 +244,7 @@ public static function flushIdentityMap(): void protected $casts = [ 'proxy' => SchemalessAttributes::class, 'traefik_outdated_info' => 'array', + 'server_metadata' => 'array', 'logdrain_axiom_api_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted', 'delete_unused_volumes' => 'boolean', @@ -258,12 +272,19 @@ public static function flushIdentityMap(): void 'is_validating', 'detected_traefik_version', 'traefik_outdated_info', + 'server_metadata', + 'ip_previous', ]; - protected $guarded = []; - use HasSafeStringAttribute; + public function setValidationLogsAttribute($value): void + { + $this->attributes['validation_logs'] = $value !== null + ? Purify::config('validation_logs')->clean($value) + : null; + } + public function type() { return 'server'; @@ -716,17 +737,17 @@ public function definedResources() public function stopUnmanaged($id) { - return instant_remote_process(["docker stop -t 0 $id"], $this); + return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this); } public function restartUnmanaged($id) { - return instant_remote_process(["docker restart $id"], $this); + return instant_remote_process(['docker restart '.escapeshellarg($id)], $this); } public function startUnmanaged($id) { - return instant_remote_process(["docker start $id"], $this); + return instant_remote_process(['docker start '.escapeshellarg($id)], $this); } public function getContainers() @@ -913,6 +934,9 @@ public function port(): Attribute return Attribute::make( get: function ($value) { return (int) preg_replace('/[^0-9]/', '', $value); + }, + set: function ($value) { + return (int) preg_replace('/[^0-9]/', '', (string) $value); } ); } @@ -922,6 +946,9 @@ public function user(): Attribute return Attribute::make( get: function ($value) { return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + }, + set: function ($value) { + return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); } ); } @@ -931,6 +958,9 @@ public function ip(): Attribute return Attribute::make( get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); + }, + set: function ($value) { + return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); } ); } @@ -1003,6 +1033,30 @@ public function team() return $this->belongsTo(Team::class); } + /** + * @return array{id?: int, name: string, uuid: string, network: string, server_id: int} + */ + public function defaultStandaloneDockerAttributes(?int $id = null): array + { + $attributes = [ + 'name' => 'coolify', + 'uuid' => (string) new Cuid2, + 'network' => 'coolify', + 'server_id' => $this->id, + ]; + + if (! is_null($id)) { + $attributes['id'] = $id; + } + + return $attributes; + } + + public function environment_variables() + { + return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server'); + } + public function isProxyShouldRun() { // TODO: Do we need "|| $this->proxy->force_stop" here? @@ -1065,6 +1119,55 @@ public function validateOS(): bool|Stringable } } + public function gatherServerMetadata(): ?array + { + if (! $this->isFunctional()) { + return null; + } + + try { + $output = instant_remote_process([ + 'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s', + ], $this, false); + + if (! $output) { + return null; + } + + $sections = []; + $currentKey = null; + foreach (explode("\n", trim($output)) as $line) { + $line = trim($line); + if (preg_match('/^---(\w+)---$/', $line, $m)) { + $currentKey = $m[1]; + } elseif ($currentKey) { + $sections[$currentKey] = $line; + } + } + + $metadata = [ + 'os' => $sections['PRETTY_NAME'] ?? 'Unknown', + 'arch' => $sections['ARCH'] ?? 'Unknown', + 'kernel' => $sections['KERNEL'] ?? 'Unknown', + 'cpus' => (int) ($sections['CPUS'] ?? 0), + 'memory_bytes' => (int) ($sections['MEMORY'] ?? 0), + 'uptime_since' => $sections['UPTIME_SINCE'] ?? null, + 'collected_at' => now()->toIso8601String(), + ]; + + $this->update(['server_metadata' => $metadata]); + + return $metadata; + } catch (\Throwable $e) { + Log::debug('Failed to gather server metadata', [ + 'server_id' => $this->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function isTerminalEnabled() { return $this->settings->is_terminal_enabled ?? false; @@ -1399,7 +1502,7 @@ public function url() public function restartContainer(string $containerName) { - return instant_remote_process(['docker restart '.$containerName], $this, false); + return instant_remote_process(['docker restart '.escapeshellarg($containerName)], $this, false); } public function changeProxy(string $proxyType, bool $async = true) @@ -1410,6 +1513,9 @@ public function changeProxy(string $proxyType, bool $async = true) if ($validProxyTypes->contains(str($proxyType)->lower())) { $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); + $this->proxy->set('last_saved_proxy_configuration', null); + $this->proxy->set('last_saved_settings', null); + $this->proxy->set('last_applied_settings', null); $this->save(); if ($this->proxySet()) { if ($async) { @@ -1452,12 +1558,14 @@ public function generateCaCertificate() $certificateContent = $caCertificate->ssl_certificate; $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($certificateContent); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$certificateContent}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 0ad0fcf84..30fc1e165 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -53,9 +53,54 @@ )] class ServerSetting extends Model { - protected $guarded = []; + protected $fillable = [ + 'server_id', + 'is_swarm_manager', + 'is_jump_server', + 'is_build_server', + 'is_reachable', + 'is_usable', + 'wildcard_domain', + 'is_cloudflare_tunnel', + 'is_logdrain_newrelic_enabled', + 'logdrain_newrelic_license_key', + 'logdrain_newrelic_base_uri', + 'is_logdrain_highlight_enabled', + 'logdrain_highlight_project_id', + 'is_logdrain_axiom_enabled', + 'logdrain_axiom_dataset_name', + 'logdrain_axiom_api_key', + 'is_swarm_worker', + 'is_logdrain_custom_enabled', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + 'concurrent_builds', + 'dynamic_timeout', + 'force_disabled', + 'is_metrics_enabled', + 'generate_exact_labels', + 'force_docker_cleanup', + 'docker_cleanup_frequency', + 'docker_cleanup_threshold', + 'server_timezone', + 'delete_unused_volumes', + 'delete_unused_networks', + 'is_sentinel_enabled', + 'sentinel_token', + 'sentinel_metrics_refresh_rate_seconds', + 'sentinel_metrics_history_days', + 'sentinel_push_interval_seconds', + 'sentinel_custom_url', + 'server_disk_usage_notification_threshold', + 'is_sentinel_debug_enabled', + 'server_disk_usage_check_frequency', + 'is_terminal_enabled', + 'deployment_queue_limit', + 'disable_application_image_retention', + ]; protected $casts = [ + 'force_disabled' => 'boolean', 'force_docker_cleanup' => 'boolean', 'docker_cleanup_threshold' => 'integer', 'sentinel_token' => 'encrypted', @@ -92,6 +137,15 @@ protected static function booted() }); } + /** + * Validate that a sentinel token contains only safe characters. + * Prevents OS command injection when the token is interpolated into shell commands. + */ + public static function isValidSentinelToken(string $token): bool + { + return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token); + } + public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false) { $data = [ diff --git a/app/Models/Service.php b/app/Models/Service.php index 84c047bb7..11189b4ac 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -15,6 +15,7 @@ use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; +use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; #[OA\Schema( @@ -47,7 +48,22 @@ class Service extends BaseModel private static $parserVersion = '5'; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'docker_compose_raw', + 'docker_compose', + 'connect_to_docker_network', + 'service_type', + 'config_hash', + 'compose_parsing_version', + 'is_container_label_escape_enabled', + 'environment_id', + 'server_id', + 'destination_id', + 'destination_type', + ]; protected $appends = ['server_status', 'status']; @@ -1552,7 +1568,7 @@ public function saveComposeConfigs() // Generate SERVICE_NAME_* environment variables from docker-compose services if ($this->docker_compose) { try { - $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $dockerCompose = Yaml::parse($this->docker_compose); $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..6bf12f4e7 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -5,12 +5,31 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Symfony\Component\Yaml\Yaml; class ServiceApplication extends BaseModel { use HasFactory, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'service_id', + 'name', + 'human_name', + 'description', + 'fqdn', + 'ports', + 'exposes', + 'status', + 'exclude_from_status', + 'required_fqdn', + 'image', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'last_online_at', + 'is_migrated', + ]; protected static function booted() { @@ -21,7 +40,7 @@ protected static function booted() }); static::saving(function ($service) { if ($service->isDirty('status')) { - $service->forceFill(['last_online_at' => now()]); + $service->last_online_at = now(); } }); } @@ -88,7 +107,7 @@ public function type() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() @@ -211,7 +230,7 @@ public function getRequiredPort(): ?int return $this->service->getRequiredPort(); } - $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $dockerCompose = Yaml::parse($dockerComposeRaw); $serviceConfig = data_get($dockerCompose, "services.{$this->name}"); if (! $serviceConfig) { return $this->service->getRequiredPort(); diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..69801f985 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -9,7 +9,32 @@ class ServiceDatabase extends BaseModel { use HasFactory, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'service_id', + 'name', + 'human_name', + 'description', + 'fqdn', + 'ports', + 'exposes', + 'status', + 'exclude_from_status', + 'image', + 'public_port', + 'is_public', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'last_online_at', + 'is_migrated', + 'custom_type', + 'public_port_timeout', + ]; + + protected $casts = [ + 'public_port_timeout' => 'integer', + ]; protected static function booted() { @@ -20,7 +45,7 @@ protected static function booted() }); static::saving(function ($service) { if ($service->isDirty('status')) { - $service->forceFill(['last_online_at' => now()]); + $service->last_online_at = now(); } }); } @@ -124,7 +149,7 @@ public function getServiceDatabaseUrl() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 7956f006a..fa6fd45e0 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -6,7 +6,27 @@ class SharedEnvironmentVariable extends Model { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Type and relationships + 'type', + 'team_id', + 'project_id', + 'environment_id', + 'server_id', + + // Boolean flags + 'is_multiline', + 'is_literal', + 'is_shown_once', + + // Metadata + 'version', + ]; protected $casts = [ 'key' => 'string', @@ -27,4 +47,9 @@ public function environment() { return $this->belongsTo(Environment::class); } + + public function server() + { + return $this->belongsTo(Server::class); + } } diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 128b25221..d4f125fb5 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -24,7 +24,8 @@ class SlackNotificationSettings extends Model 'backup_failure_slack_notifications', 'scheduled_task_success_slack_notifications', 'scheduled_task_failure_slack_notifications', - 'docker_cleanup_slack_notifications', + 'docker_cleanup_success_slack_notifications', + 'docker_cleanup_failure_slack_notifications', 'server_disk_usage_slack_notifications', 'server_reachable_slack_notifications', 'server_unreachable_slack_notifications', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 86323db8c..784e2c937 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -13,12 +13,44 @@ class StandaloneClickhouse extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'clickhouse_admin_user', + 'clickhouse_admin_password', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'custom_docker_run_options', + 'clickhouse_db', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ - 'clickhouse_password' => 'encrypted', + 'clickhouse_admin_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -43,7 +75,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -134,7 +166,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 62ef68434..dcb349405 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -3,26 +3,43 @@ namespace App\Models; use App\Jobs\ConnectProxyToNetworksJob; +use App\Support\ValidationPatterns; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; class StandaloneDocker extends BaseModel { + use HasFactory; use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'server_id', + 'name', + 'network', + ]; protected static function boot() { parent::boot(); static::created(function ($newStandaloneDocker) { $server = $newStandaloneDocker->server; + $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ - "docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null", + "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null", ], $server, false); ConnectProxyToNetworksJob::dispatchSync($server); }); } + public function setNetworkAttribute(string $value): void + { + if (! ValidationPatterns::isValidDockerNetwork($value)) { + throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.'); + } + + $this->attributes['network'] = $value; + } + public function applications() { return $this->morphMany(Application::class, 'destination'); diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 4db7866b7..e07053c03 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -13,12 +13,43 @@ class StandaloneDragonfly extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'dragonfly_password', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ 'dragonfly_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -43,7 +74,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -134,7 +165,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index f23499608..979f45a3d 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -13,12 +13,44 @@ class StandaloneKeydb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'keydb_password', + 'keydb_conf', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; protected $casts = [ 'keydb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -43,7 +75,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -134,7 +166,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index e7ba75b67..dba8a52f5 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -14,12 +14,46 @@ class StandaloneMariadb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'mariadb_root_password', + 'mariadb_user', + 'mariadb_password', + 'mariadb_database', + 'mariadb_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'is_log_drain_enabled', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ 'mariadb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -44,7 +78,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -135,7 +169,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index d6de5874c..e72f4f1c6 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -13,11 +13,46 @@ class StandaloneMongodb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'mongo_conf', + 'mongo_initdb_root_username', + 'mongo_initdb_root_password', + 'mongo_initdb_database', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'ssl_mode', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -49,7 +84,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -140,7 +175,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 98a5cab77..1c522d200 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -13,13 +13,49 @@ class StandaloneMysql extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'mysql_root_password', + 'mysql_user', + 'mysql_password', + 'mysql_database', + 'mysql_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'ssl_mode', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -44,7 +80,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -135,7 +171,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 5d35f335b..57dfe5988 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -13,13 +13,51 @@ class StandalonePostgresql extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'postgres_user', + 'postgres_password', + 'postgres_db', + 'postgres_initdb_args', + 'postgres_host_auth_method', + 'postgres_conf', + 'init_scripts', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'ssl_mode', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ 'init_scripts' => 'array', 'postgres_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -58,7 +96,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); } @@ -113,7 +151,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index e906bbb81..ef42d7f18 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -13,11 +13,42 @@ class StandaloneRedis extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'uuid', + 'name', + 'description', + 'redis_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + 'public_port_timeout', + 'enable_ssl', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'custom_docker_run_options', + 'destination_type', + 'destination_id', + 'environment_id', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -42,7 +73,7 @@ protected static function booted() }); static::saving(function ($database) { if ($database->isDirty('status')) { - $database->forceFill(['last_online_at' => now()]); + $database->last_online_at = now(); } }); @@ -139,7 +170,7 @@ public function deleteVolumes() } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { - instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 1bd84a664..b0fec64f9 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -6,13 +6,46 @@ class Subscription extends Model { - protected $guarded = []; + protected $fillable = [ + 'team_id', + 'stripe_invoice_paid', + 'stripe_subscription_id', + 'stripe_customer_id', + 'stripe_cancel_at_period_end', + 'stripe_plan_id', + 'stripe_feedback', + 'stripe_comment', + 'stripe_trial_already_ended', + 'stripe_past_due', + 'stripe_refunded_at', + ]; + + protected function casts(): array + { + return [ + 'stripe_refunded_at' => 'datetime', + ]; + } public function team() { return $this->belongsTo(Team::class); } + public function billingInterval(): string + { + if ($this->stripe_plan_id) { + $configKey = collect(config('subscription')) + ->search($this->stripe_plan_id); + + if ($configKey && str($configKey)->contains('yearly')) { + return 'yearly'; + } + } + + return 'monthly'; + } + public function type() { if (isStripe()) { diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 08be81970..134e36189 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -2,9 +2,24 @@ namespace App\Models; +use App\Support\ValidationPatterns; + class SwarmDocker extends BaseModel { - protected $guarded = []; + protected $fillable = [ + 'server_id', + 'name', + 'network', + ]; + + public function setNetworkAttribute(string $value): void + { + if (! ValidationPatterns::isValidDockerNetwork($value)) { + throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.'); + } + + $this->attributes['network'] = $value; + } public function applications() { diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 3594d1072..e6fbd3a06 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -8,7 +8,10 @@ class Tag extends BaseModel { use HasSafeStringAttribute; - protected $guarded = []; + protected $fillable = [ + 'name', + 'team_id', + ]; protected function customizeName($value) { diff --git a/app/Models/Team.php b/app/Models/Team.php index 5cb186942..d5d564444 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -40,7 +40,13 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen { use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'personal_team', + 'show_boarding', + 'custom_server_limit', + ]; protected $casts = [ 'personal_team' => 'boolean', @@ -65,34 +71,43 @@ protected static function booted() } }); - static::deleting(function ($team) { - $keys = $team->privateKeys; - foreach ($keys as $key) { + static::deleting(function (Team $team) { + foreach ($team->privateKeys as $key) { $key->delete(); } - $sources = $team->sources(); - foreach ($sources as $source) { + + // Transfer instance-wide sources to root team so they remain available + GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); + GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); + + // Delete non-instance-wide sources owned by this team + $teamSources = GithubApp::where('team_id', $team->id)->get() + ->merge(GitlabApp::where('team_id', $team->id)->get()); + foreach ($teamSources as $source) { $source->delete(); } - $tags = Tag::whereTeamId($team->id)->get(); - foreach ($tags as $tag) { + + foreach (Tag::whereTeamId($team->id)->get() as $tag) { $tag->delete(); } - $shared_variables = $team->environment_variables(); - foreach ($shared_variables as $shared_variable) { - $shared_variable->delete(); + + foreach ($team->environment_variables()->get() as $sharedVariable) { + $sharedVariable->delete(); } - $s3s = $team->s3s; - foreach ($s3s as $s3) { + + foreach ($team->s3s as $s3) { $s3->delete(); } }); } - public static function serverLimitReached() + public static function serverLimitReached(?Team $team = null) { - $serverLimit = Team::serverLimit(); - $team = currentTeam(); + $team = $team ?? currentTeam(); + if (! $team) { + return true; + } + $serverLimit = Team::serverLimit($team); $servers = $team->servers->count(); return $servers >= $serverLimit; @@ -109,19 +124,23 @@ public function subscriptionPastOverDue() public function serverOverflow() { - if ($this->serverLimit() < $this->servers->count()) { + if (Team::serverLimit($this) < $this->servers->count()) { return true; } return false; } - public static function serverLimit() + public static function serverLimit(?Team $team = null) { - if (currentTeam()->id === 0 && isDev()) { + $team = $team ?? currentTeam(); + if (! $team) { + return 0; + } + if ($team->id === 0 && isDev()) { return 9999999; } - $team = Team::find(currentTeam()->id); + $team = Team::find($team->id); if (! $team) { return 0; } @@ -191,11 +210,16 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('discord')?->isEnabled() || $this->getNotificationSettings('slack')?->isEnabled() || $this->getNotificationSettings('telegram')?->isEnabled() || - $this->getNotificationSettings('pushover')?->isEnabled(); + $this->getNotificationSettings('pushover')?->isEnabled() || + $this->getNotificationSettings('webhook')?->isEnabled(); } public function subscriptionEnded() { + if (! $this->subscription) { + return; + } + $this->subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, @@ -214,7 +238,7 @@ public function subscriptionEnded() public function environment_variables() { - return $this->hasMany(SharedEnvironmentVariable::class)->whereNull('project_id')->whereNull('environment_id'); + return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'team'); } public function members() @@ -320,4 +344,5 @@ public function webhookNotificationSettings() { return $this->hasOne(WebhookNotificationSettings::class); } + } diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 73889910e..4930f45d4 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -25,7 +25,8 @@ class TelegramNotificationSettings extends Model 'backup_failure_telegram_notifications', 'scheduled_task_success_telegram_notifications', 'scheduled_task_failure_telegram_notifications', - 'docker_cleanup_telegram_notifications', + 'docker_cleanup_success_telegram_notifications', + 'docker_cleanup_failure_telegram_notifications', 'server_disk_usage_telegram_notifications', 'server_reachable_telegram_notifications', 'server_unreachable_telegram_notifications', @@ -39,7 +40,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', 'telegram_notifications_scheduled_task_failure_thread_id', - 'telegram_notifications_docker_cleanup_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', 'telegram_notifications_server_disk_usage_thread_id', 'telegram_notifications_server_reachable_thread_id', 'telegram_notifications_server_unreachable_thread_id', diff --git a/app/Models/User.php b/app/Models/User.php index 4561cddb2..3199d2024 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,7 +4,9 @@ use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; +use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; +use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,7 +43,16 @@ class User extends Authenticatable implements SendsEmail { use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; - protected $guarded = []; + protected $fillable = [ + 'name', + 'email', + 'password', + 'force_password_reset', + 'marketing_emails', + 'pending_email', + 'email_change_code', + 'email_change_code_expires_at', + ]; protected $hidden = [ 'password', @@ -87,7 +98,8 @@ protected static function boot() $team['id'] = 0; $team['name'] = 'Root Team'; } - $new_team = Team::create($team); + $new_team = (new Team)->forceFill($team); + $new_team->save(); $user->teams()->attach($new_team, ['role' => 'owner']); }); @@ -190,7 +202,8 @@ public function recreate_personal_team() $team['id'] = 0; $team['name'] = 'Root Team'; } - $new_team = Team::create($team); + $new_team = (new Team)->forceFill($team); + $new_team->save(); $this->teams()->attach($new_team, ['role' => 'owner']); return $new_team; @@ -228,7 +241,7 @@ public function changelogReads() public function getUnreadChangelogCount(): int { - return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this); + return app(ChangelogService::class)->getUnreadCountForUser($this); } public function getRecipients(): array @@ -239,7 +252,7 @@ public function getRecipients(): array public function sendVerificationEmail() { $mail = new MailMessage; - $url = Url::temporarySignedRoute( + $url = URL::temporarySignedRoute( 'verify.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ @@ -395,20 +408,20 @@ public function canAccessSystemResources(): bool public function requestEmailChange(string $newEmail): void { // Generate 6-digit code - $code = sprintf('%06d', mt_rand(0, 999999)); + $code = sprintf('%06d', random_int(0, 999999)); // Set expiration using config value $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); $expiresAt = Carbon::now()->addMinutes($expiryMinutes); - $this->update([ + $this->fill([ 'pending_email' => $newEmail, 'email_change_code' => $code, 'email_change_code_expires_at' => $expiresAt, - ]); + ])->save(); // Send verification email to new address - $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt)); + $this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt)); } public function isEmailChangeCodeValid(string $code): bool diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 7a1f7bcbf..4b56f5860 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -40,7 +40,7 @@ public function toDiscord(): DiscordMessage color: DiscordMessage::errorColor(), ); - $message->addField('Please update your subscription to enable the server again!', '[Link](https://app.coolify.io/subscriptions)'); + $message->addField('Please update your subscription to enable the server again!', '[Link](https://app.coolify.io/subscription)'); return $message; } @@ -48,7 +48,7 @@ public function toDiscord(): DiscordMessage public function toTelegram(): array { return [ - 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).", + 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscription).", ]; } @@ -57,7 +57,7 @@ public function toPushover(): PushoverMessage return new PushoverMessage( title: 'Server disabled', level: 'error', - message: "Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.
Please update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).", + message: "Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.
Please update your subscription to enable the server again [here](https://app.coolify.io/subscription).", ); } @@ -66,7 +66,7 @@ public function toSlack(): SlackMessage $title = 'Server disabled'; $description = "Server ({$this->server->name}) disabled because it is not paid!\n"; $description .= "All automations and integrations are stopped.\n\n"; - $description .= 'Please update your subscription to enable the server again: https://app.coolify.io/subscriptions'; + $description .= 'Please update your subscription to enable the server again: https://app.coolify.io/subscription'; return new SlackMessage( title: $title, diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 179c8d948..511818e21 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -67,9 +67,12 @@ protected function resetUrl($notifiable) return call_user_func(static::$createUrlCallback, $notifiable, $this->token); } - return url(route('password.reset', [ + $path = route('password.reset', [ 'token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset(), - ], false)); + ], false); + + // Use server-side config (FQDN / public IP) instead of request host + return rtrim(base_url(), '/').$path; } } diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php index 154648599..3e1f83d12 100644 --- a/app/Policies/StandaloneDockerPolicy.php +++ b/app/Policies/StandaloneDockerPolicy.php @@ -37,8 +37,7 @@ public function create(User $user): bool */ public function update(User $user, StandaloneDocker $standaloneDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); - return true; + return $user->teams->contains('id', $standaloneDocker->server->team_id); } /** @@ -46,8 +45,7 @@ public function update(User $user, StandaloneDocker $standaloneDocker): bool */ public function delete(User $user, StandaloneDocker $standaloneDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); - return true; + return $user->teams->contains('id', $standaloneDocker->server->team_id); } /** @@ -55,8 +53,7 @@ public function delete(User $user, StandaloneDocker $standaloneDocker): bool */ public function restore(User $user, StandaloneDocker $standaloneDocker): bool { - // return false; - return true; + return false; } /** @@ -64,7 +61,6 @@ public function restore(User $user, StandaloneDocker $standaloneDocker): bool */ public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool { - // return false; - return true; + return false; } } diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php index 979bb5889..82a75910b 100644 --- a/app/Policies/SwarmDockerPolicy.php +++ b/app/Policies/SwarmDockerPolicy.php @@ -37,8 +37,7 @@ public function create(User $user): bool */ public function update(User $user, SwarmDocker $swarmDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); - return true; + return $user->teams->contains('id', $swarmDocker->server->team_id); } /** @@ -46,8 +45,7 @@ public function update(User $user, SwarmDocker $swarmDocker): bool */ public function delete(User $user, SwarmDocker $swarmDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); - return true; + return $user->teams->contains('id', $swarmDocker->server->team_id); } /** @@ -55,8 +53,7 @@ public function delete(User $user, SwarmDocker $swarmDocker): bool */ public function restore(User $user, SwarmDocker $swarmDocker): bool { - // return false; - return true; + return false; } /** @@ -64,7 +61,6 @@ public function restore(User $user, SwarmDocker $swarmDocker): bool */ public function forceDelete(User $user, SwarmDocker $swarmDocker): bool { - // return false; - return true; + return false; } } diff --git a/app/Rules/SafeExternalUrl.php b/app/Rules/SafeExternalUrl.php new file mode 100644 index 000000000..41299d6c1 --- /dev/null +++ b/app/Rules/SafeExternalUrl.php @@ -0,0 +1,81 @@ + $attribute, + 'url' => $value, + 'host' => $host, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute must not point to internal hosts.'); + + return; + } + + // Resolve hostname to IP and block private/reserved ranges + $ip = gethostbyname($host); + + // gethostbyname returns the original hostname on failure (e.g. unresolvable) + if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) { + $fail('The :attribute host could not be resolved.'); + + return; + } + + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + Log::warning('External URL resolves to private or reserved IP', [ + 'attribute' => $attribute, + 'url' => $value, + 'host' => $host, + 'resolved_ip' => $ip, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute must not point to a private or reserved IP address.'); + + return; + } + } +} diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php new file mode 100644 index 000000000..fbeb406af --- /dev/null +++ b/app/Rules/SafeWebhookUrl.php @@ -0,0 +1,95 @@ + $attribute, + 'host' => $host, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute must not point to localhost or internal hosts.'); + + return; + } + + // Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly + if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) { + Log::warning('Webhook URL points to blocked IP range', [ + 'attribute' => $attribute, + 'host' => $host, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute must not point to loopback or link-local addresses.'); + + return; + } + } + + private function isLoopback(string $ip): bool + { + // 127.0.0.0/8, 0.0.0.0 + if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { + return true; + } + + // IPv6 loopback + $normalized = @inet_pton($ip); + + return $normalized !== false && $normalized === inet_pton('::1'); + } + + private function isLinkLocal(string $ip): bool + { + // 169.254.0.0/16 — covers cloud metadata at 169.254.169.254 + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + $long = ip2long($ip); + + return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16); + } +} diff --git a/app/Rules/ValidDnsServers.php b/app/Rules/ValidDnsServers.php new file mode 100644 index 000000000..e3bbd048f --- /dev/null +++ b/app/Rules/ValidDnsServers.php @@ -0,0 +1,35 @@ + 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + + if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) { $invalidEntries[] = $entry; } } else { diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php new file mode 100644 index 000000000..270ff1c34 --- /dev/null +++ b/app/Rules/ValidServerIp.php @@ -0,0 +1,40 @@ +validate($attribute, $trimmed, function () use (&$failed) { + $failed = true; + }); + + if ($failed) { + $fail('The :attribute must be a valid IPv4 address, IPv6 address, or hostname.'); + } + } +} diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 2be36d905..8859a9980 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -54,13 +54,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $maxRestartCount = 0; } - if ($maxRestartCount > 1000) { - Log::warning('High maxRestartCount detected', [ - 'maxRestartCount' => $maxRestartCount, - 'containers' => $containerStatuses->count(), - ]); - } - if ($containerStatuses->isEmpty()) { return 'exited'; } @@ -138,13 +131,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $maxRestartCount = 0; } - if ($maxRestartCount > 1000) { - Log::warning('High maxRestartCount detected', [ - 'maxRestartCount' => $maxRestartCount, - 'containers' => $containers->count(), - ]); - } - if ($containers->isEmpty()) { return 'exited'; } diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php new file mode 100644 index 000000000..6e29851df --- /dev/null +++ b/app/Services/SchedulerLogParser.php @@ -0,0 +1,188 @@ + + */ + public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $skips = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null || ! isset($entry['context']['skip_reason'])) { + continue; + } + + if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { + continue; + } + + $skips->push([ + 'timestamp' => $entry['timestamp'], + 'type' => $entry['context']['type'] ?? 'unknown', + 'reason' => $entry['context']['skip_reason'], + 'team_id' => $entry['context']['team_id'] ?? null, + 'context' => $entry['context'], + ]); + } + } + + return $skips->sortByDesc('timestamp')->values()->take($limit); + } + + /** + * Get recent manager execution logs (start/complete events). + * + * @return Collection + */ + public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $runs = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null) { + continue; + } + + if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { + continue; + } + + $runs->push([ + 'timestamp' => $entry['timestamp'], + 'message' => $entry['message'], + 'duration_ms' => $entry['context']['duration_ms'] ?? null, + 'dispatched' => $entry['context']['dispatched'] ?? null, + 'skipped' => $entry['context']['skipped'] ?? null, + ]); + } + } + + return $runs->sortByDesc('timestamp')->values()->take($limit); + } + + private function getLogFiles(): array + { + $logDir = storage_path('logs'); + if (! File::isDirectory($logDir)) { + return []; + } + + $files = File::glob($logDir.'/scheduled-*.log'); + + // Sort by modification time, newest first + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + + // Only check last 3 days of logs + return array_slice($files, 0, 3); + } + + /** + * @return array{timestamp: string, level: string, message: string, context: array}|null + */ + private function parseLogLine(string $line): ?array + { + // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} + if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { + return null; + } + + $timestamp = $matches[1]; + $level = $matches[2]; + $rest = $matches[3]; + + // Extract JSON context if present + $context = []; + if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { + $message = $contextMatches[1]; + $decoded = json_decode($contextMatches[2], true); + if (is_array($decoded)) { + $context = $decoded; + } + } else { + $message = $rest; + } + + return [ + 'timestamp' => $timestamp, + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + /** + * Efficiently read the last N lines of a file. + * + * @return string[] + */ + private function readLastLines(string $filePath, int $lines): array + { + if (! File::exists($filePath)) { + return []; + } + + $fileSize = File::size($filePath); + if ($fileSize === 0) { + return []; + } + + // For small files, read the whole thing + if ($fileSize < 1024 * 1024) { + $content = File::get($filePath); + + return array_filter(explode("\n", $content), fn ($line) => $line !== ''); + } + + // For large files, read from the end + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return []; + } + + $result = []; + $chunkSize = 8192; + $buffer = ''; + $position = $fileSize; + + while ($position > 0 && count($result) < $lines) { + $readSize = min($chunkSize, $position); + $position -= $readSize; + fseek($handle, $position); + $buffer = fread($handle, $readSize).$buffer; + + $bufferLines = explode("\n", $buffer); + $buffer = array_shift($bufferLines); + + $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); + } + + if ($buffer !== '' && count($result) < $lines) { + array_unshift($result, $buffer); + } + + fclose($handle); + + return array_slice($result, -$lines); + } +} diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index a2da0fc46..88121384f 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -9,14 +9,63 @@ class ValidationPatterns { /** * Pattern for names excluding all dangerous characters - */ - public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; + */ + public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u'; /** * Pattern for descriptions excluding all dangerous characters with some additional allowed characters */ public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u'; + /** + * Pattern for file paths (dockerfile location, docker compose location, etc.) + * Allows alphanumeric, dots, hyphens, underscores, slashes, @, ~, and + + */ + public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/'; + + /** + * Pattern for directory paths (base_directory, publish_directory, etc.) + * Like FILE_PATH_PATTERN but also allows bare "/" (root directory) + */ + public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/'; + + /** + * Pattern for Docker build target names (multi-stage build stage names) + * Allows alphanumeric, dots, hyphens, and underscores + */ + public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + + /** + * Pattern for shell-safe command strings (docker compose commands, docker run options) + * Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns + * Allows & for command chaining (&&) which is common in multi-step build commands + * Allows double quotes for build args with spaces (e.g. --build-arg KEY="value") + * Blocks backslashes to prevent escape-sequence attacks + * Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'") + * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators) + */ + public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/'; + + /** + * Pattern for Docker volume names + * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores + * Matches Docker's volume naming rules + */ + public const VOLUME_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + + /** + * Pattern for Docker container names + * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores + */ + public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + + /** + * Pattern for Docker network names + * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores + * Matches Docker's network naming rules and prevents shell injection + */ + public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** * Get validation rules for name fields */ @@ -64,7 +113,7 @@ public static function descriptionRules(bool $required = false, int $maxLength = public static function nameMessages(): array { return [ - 'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &", + 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +', 'name.min' => 'The name must be at least :min characters.', 'name.max' => 'The name may not be greater than :max characters.', ]; @@ -81,7 +130,171 @@ public static function descriptionMessages(): array ]; } - /** + /** + * Get validation rules for file path fields (dockerfile location, docker compose location) + */ + public static function filePathRules(int $maxLength = 255): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::FILE_PATH_PATTERN]; + } + + /** + * Get validation messages for file path fields + */ + public static function filePathMessages(string $field = 'dockerfileLocation', string $label = 'Dockerfile'): array + { + return [ + "{$field}.regex" => "The {$label} location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, underscores, slashes, @, ~, and +.", + ]; + } + + /** + * Get validation rules for directory path fields (base_directory, publish_directory) + */ + public static function directoryPathRules(int $maxLength = 255): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN]; + } + + /** + * Get validation rules for Docker build target fields + */ + public static function dockerTargetRules(int $maxLength = 128): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN]; + } + + /** + * Get validation rules for shell-safe command fields + */ + public static function shellSafeCommandRules(int $maxLength = 1000): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN]; + } + + /** + * Get validation rules for Docker volume name fields + */ + public static function volumeNameRules(bool $required = true, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::VOLUME_NAME_PATTERN; + + return $rules; + } + + /** + * Get validation messages for volume name fields + */ + public static function volumeNameMessages(string $field = 'name'): array + { + return [ + "{$field}.regex" => 'The volume name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.', + ]; + } + + /** + * Pattern for port mappings with optional IP binding and protocol suffix on either side. + * Format: [ip:]port[:ip:port] where IP is IPv4 or [IPv6], port can be a range, protocol suffix optional. + * Examples: 8080:80, 127.0.0.1:8080:80, [::1]::80/udp, 127.0.0.1:8080:80/tcp + */ + public const PORT_MAPPINGS_PATTERN = '/^ + (?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)? # optional IP + (?:\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?)? # optional host port + : + (?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)? # optional IP + \d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))? # container port + (?:, + (?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)? + (?:\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?)? + : + (?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)? + \d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))? + )* + $/x'; + + /** + * Get validation rules for container name fields + */ + public static function containerNameRules(int $maxLength = 255): array + { + return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN]; + } + + /** + * Get validation rules for port mapping fields + */ + public static function portMappingRules(): array + { + return ['nullable', 'string', 'regex:'.self::PORT_MAPPINGS_PATTERN]; + } + + /** + * Get validation messages for port mapping fields + */ + public static function portMappingMessages(string $field = 'portsMappings'): array + { + return [ + "{$field}.regex" => 'Port mappings must be a comma-separated list of port pairs or ranges with optional IP and protocol (e.g. 3000:3000, 8080:80/udp, 127.0.0.1:8080:80, [::1]::80).', + ]; + } + + /** + * Check if a string is a valid Docker container name. + */ + public static function isValidContainerName(string $name): bool + { + return preg_match(self::CONTAINER_NAME_PATTERN, $name) === 1; + } + + /** + * Get validation rules for Docker network name fields + */ + public static function dockerNetworkRules(bool $required = true, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_NETWORK_PATTERN; + + return $rules; + } + + /** + * Get validation messages for Docker network name fields + */ + public static function dockerNetworkMessages(string $field = 'network'): array + { + return [ + "{$field}.regex" => 'The network name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.', + ]; + } + + /** + * Check if a string is a valid Docker network name. + */ + public static function isValidDockerNetwork(string $name): bool + { + return preg_match(self::DOCKER_NETWORK_PATTERN, $name) === 1; + } + + /** * Get combined validation messages for both name and description fields */ public static function combinedMessages(): array diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a60a47b93..bb252148a 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -3,6 +3,7 @@ namespace App\Traits; use App\Enums\ApplicationDeploymentStatus; +use App\Exceptions\DeploymentException; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Carbon\Carbon; @@ -77,6 +78,7 @@ public function execute_remote_command(...$commands) $customType = data_get($single_command, 'type'); $ignore_errors = data_get($single_command, 'ignore_errors', false); $append = data_get($single_command, 'append', true); + $command_hidden = data_get($single_command, 'command_hidden', false); $this->save = data_get($single_command, 'save'); if ($this->server->isNonRoot()) { if (str($command)->startsWith('docker exec')) { @@ -101,9 +103,9 @@ public function execute_remote_command(...$commands) while ($attempt < $maxRetries && ! $commandExecuted) { try { - $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors); + $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden); $commandExecuted = true; - } catch (\RuntimeException $e) { + } catch (\RuntimeException|DeploymentException $e) { $lastError = $e; $errorMessage = $e->getMessage(); // Only retry if it's an SSH connection error and we haven't exhausted retries @@ -111,13 +113,6 @@ public function execute_remote_command(...$commands) $attempt++; $delay = $this->calculateRetryDelay($attempt - 1); - // 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', - ]); - // Add log entry for the retry if (isset($this->application_deployment_queue)) { $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); @@ -158,10 +153,14 @@ public function execute_remote_command(...$commands) /** * Execute the actual command with process handling */ - private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors) + private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false) { + if ($command_hidden && isset($this->application_deployment_queue)) { + $this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true); + } + $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); - $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { + $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden) { $output = str($output)->trim(); if ($output->startsWith('╔')) { $output = "\n".$output; @@ -171,9 +170,9 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe $sanitized_output = sanitize_utf8_text($output); $new_log_entry = [ - 'command' => $this->redact_sensitive_info($command), + 'command' => $command_hidden ? null : $this->redact_sensitive_info($command), 'output' => $this->redact_sensitive_info($sanitized_output), - 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', + 'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'), 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, 'batch' => static::$batch_counter, @@ -240,7 +239,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe $error = $process_result->output() ?: 'Command failed with no error output'; } $redactedCommand = $this->redact_sensitive_info($command); - throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); + throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); } } } diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 667d58441..7ed82cc91 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -2,6 +2,8 @@ namespace App\Traits; +use App\Models\ServerSetting; + trait HasMetrics { public function getCpuMetrics(int $mins = 5): ?array @@ -26,8 +28,13 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array $from = now()->subMinutes($mins)->toIso8601ZuluString(); $endpoint = $this->getMetricsEndpoint($type, $from); + $token = $server->settings->sentinel_token; + if (! ServerSetting::isValidSentinelToken($token)) { + throw new \Exception('Invalid sentinel token format. Please regenerate the token.'); + } + $response = instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"], + ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"], $server, false ); diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index a26481056..2092dc5f3 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -95,9 +95,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [], 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); @@ -133,42 +130,4 @@ protected function executeWithSshRetry(callable $callback, array $context = [], 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/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index 4a98e4a51..faef64a36 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -38,6 +38,7 @@ public function __construct( public array $availableVars = [], public ?string $projectUuid = null, public ?string $environmentUuid = null, + public ?string $serverUuid = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { @@ -86,6 +87,9 @@ public function render(): View|Closure|string 'environment_uuid' => $this->environmentUuid, ]) : route('shared-variables.environment.index'), + 'server' => $this->serverUuid + ? route('shared-variables.server.show', ['server_uuid' => $this->serverUuid]) + : route('shared-variables.server.index'), 'default' => route('shared-variables.index'), ]; diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php index a5303b947..02a23a26a 100644 --- a/app/View/Components/Forms/Textarea.php +++ b/app/View/Components/Forms/Textarea.php @@ -32,10 +32,11 @@ public function __construct( public bool $allowTab = false, public bool $spellcheck = false, public bool $autofocus = false, + public bool $monospace = false, public ?string $helper = null, public bool $realtimeValidation = false, public bool $allowToPeak = true, - public string $defaultClass = 'input scrollbar font-mono', + public string $defaultClass = 'input scrollbar', public string $defaultClassInput = 'input', public ?int $minlength = null, public ?int $maxlength = null, @@ -81,6 +82,10 @@ public function render(): View|Closure|string $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } + if ($this->monospace) { + $this->defaultClass .= ' font-mono'; + } + // $this->label = Str::title($this->label); return view('components.forms.textarea'); } diff --git a/boost.json b/boost.json index 34b67ce76..13914521e 100644 --- a/boost.json +++ b/boost.json @@ -6,18 +6,23 @@ "opencode" ], "guidelines": true, - "herd_mcp": false, "mcp": true, + "nightwatch_mcp": false, "packages": [ "laravel/fortify", - "spatie/laravel-ray" + "spatie/laravel-ray", + "lorisleiva/laravel-actions" ], "sail": false, "skills": [ + "laravel-best-practices", + "configuring-horizon", + "socialite-development", "livewire-development", "pest-testing", "tailwindcss-development", - "developing-with-fortify", + "fortify-development", + "laravel-actions", "debugging-output-and-previewing-html-using-ray" ] } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index d5c2c996b..8088e6b99 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -92,24 +92,26 @@ function sharedDataApplications() 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), - 'git_commit_sha' => 'string', + 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => 'string|nullable', 'docker_registry_image_tag' => 'string|nullable', - 'install_command' => 'string|nullable', - 'build_command' => 'string|nullable', - 'start_command' => 'string|nullable', + 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', - 'base_directory' => 'string|nullable', - 'publish_directory' => 'string|nullable', + 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), + 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), 'health_check_enabled' => 'boolean', - 'health_check_path' => 'string', - 'health_check_port' => 'string|nullable', - 'health_check_host' => 'string', - 'health_check_method' => 'string', + 'health_check_type' => 'string|in:http,cmd', + 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], + 'health_check_port' => 'integer|nullable|min:1|max:65535', + 'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS', 'health_check_return_code' => 'numeric', - 'health_check_scheme' => 'string', + 'health_check_scheme' => 'string|in:http,https', 'health_check_response_text' => 'string|nullable', 'health_check_interval' => 'numeric', 'health_check_timeout' => 'numeric', @@ -123,22 +125,26 @@ function sharedDataApplications() 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'custom_labels' => 'string|nullable', - 'custom_docker_run_options' => 'string|nullable', + 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), + // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). + // Access is gated by API token authentication. Commands run inside the app container, not the host. 'post_deployment_command' => 'string|nullable', - 'post_deployment_command_container' => 'string', + 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'pre_deployment_command' => 'string|nullable', - 'pre_deployment_command_container' => 'string', + 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => 'string|nullable', - 'docker_compose_location' => 'string', + 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), + 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', + 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', + 'is_preserve_repository_enabled' => 'boolean' ]; } @@ -188,5 +194,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('force_domain_override'); $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); + $request->offsetUnset('is_preserve_repository_enabled'); $request->offsetUnset('docker_compose_raw'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 03c53989c..48e0a8c78 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -6,12 +6,13 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; -function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false) +function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) { $application_id = $application->id; $deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}"); @@ -46,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id) ->where('commit', $commit) ->where('pull_request_id', $pull_request_id) + ->where('docker_registry_image_tag', $docker_registry_image_tag) ->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value]) ->first(); @@ -71,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme 'deployment_uuid' => $deployment_uuid, 'deployment_url' => $deployment_url, 'pull_request_id' => $pull_request_id, + 'docker_registry_image_tag' => $docker_registry_image_tag, 'force_rebuild' => $force_rebuild, 'is_webhook' => $is_webhook, 'is_api' => $is_api, @@ -191,6 +194,10 @@ function clone_application(Application $source, $destination, array $overrides = $uuid = $overrides['uuid'] ?? (string) new Cuid2; $server = $destination->server; + if ($server->team_id !== currentTeam()->id) { + throw new RuntimeException('Destination does not belong to the current team.'); + } + // Prepare name and URL $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid; $applicationSettings = $source->settings; @@ -234,6 +241,7 @@ function clone_application(Application $source, $destination, array $overrides = 'application_id' => $newApplication->id, ]); $newApplicationSettings->save(); + $newApplication->setRelation('settings', $newApplicationSettings->fresh()); } // Clone tags @@ -295,6 +303,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', + 'uuid', ])->fill([ 'name' => $newName, 'resource_id' => $newApplication->id, @@ -318,8 +327,8 @@ function clone_application(Application $source, $destination, array $overrides = destination: $source->destination, no_questions_asked: true ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } catch (Exception $e) { + Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } } } @@ -340,7 +349,7 @@ function clone_application(Application $source, $destination, array $overrides = // 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) { + EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { $newEnvironmentVariable = $environmentVariable->replicate([ 'id', 'created_at', @@ -357,7 +366,7 @@ function clone_application(Application $source, $destination, array $overrides = // Clone preview environment variables $previewEnvironmentVariables = $source->environment_variables_preview()->get(); foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { - \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { + EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([ 'id', 'created_at', diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 30ca36f2e..bae2573de 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -81,4 +81,4 @@ const NEEDS_TO_DISABLE_STRIPPREFIX = [ 'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'], ]; -const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment']; +const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment', 'server']; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 8212f9dc6..5905ed3c1 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -137,10 +137,16 @@ function checkMinimumDockerEngineVersion($dockerVersion) return $dockerVersion; } +function escapeShellValue(string $value): string +{ + return "'".str_replace("'", "'\\''", $value)."'"; +} + function executeInDocker(string $containerId, string $command) { - return "docker exec {$containerId} bash -c '{$command}'"; - // return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'"; + $escapedCommand = str_replace("'", "'\\''", $command); + + return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) @@ -1005,6 +1011,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--ulimit' => 'ulimits', '--privileged' => 'privileged', '--ip' => 'ip', + '--ip6' => 'ip6', '--shm-size' => 'shm_size', '--gpus' => 'gpus', '--hostname' => 'hostname', diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 43ba58e59..123cf906a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -22,25 +22,25 @@ * * @param string $composeYaml The raw Docker Compose YAML content * - * @throws \Exception If the compose file contains command injection attempts + * @throws Exception If the compose file contains command injection attempts */ function validateDockerComposeForInjection(string $composeYaml): void { try { $parsed = Yaml::parse($composeYaml); - } catch (\Exception $e) { - throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e); + } catch (Exception $e) { + throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e); } if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) { - throw new \Exception('Docker Compose file must contain a "services" section'); + throw new Exception('Docker Compose file must contain a "services" section'); } // Validate service names foreach ($parsed['services'] as $serviceName => $serviceConfig) { try { validateShellSafePath($serviceName, 'service name'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker Compose service name: '.$e->getMessage(). ' Service names must not contain shell metacharacters.', 0, @@ -68,8 +68,8 @@ function validateDockerComposeForInjection(string $composeYaml): void if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { try { validateShellSafePath($source, 'volume source'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.', 0, @@ -84,8 +84,8 @@ function validateDockerComposeForInjection(string $composeYaml): void if (is_string($target)) { try { validateShellSafePath($target, 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.', 0, @@ -105,7 +105,7 @@ function validateDockerComposeForInjection(string $composeYaml): void * * @param string $volumeString The volume string to validate * - * @throws \Exception If the volume string contains command injection attempts + * @throws Exception If the volume string contains command injection attempts */ function validateVolumeStringForInjection(string $volumeString): void { @@ -325,9 +325,9 @@ function parseDockerVolumeString(string $volumeString): array if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceStr, 'volume source'); - } catch (\Exception $e) { + } catch (Exception $e) { // Re-throw with more context about the volume string - throw new \Exception( + throw new Exception( 'Invalid Docker volume definition: '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -343,8 +343,8 @@ function parseDockerVolumeString(string $volumeString): array // Still, defense in depth is important try { validateShellSafePath($targetStr, 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition: '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -375,7 +375,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int try { $yaml = Yaml::parse($compose); - } catch (\Exception) { + } catch (Exception) { return collect([]); } $services = data_get($yaml, 'services', collect([])); @@ -409,8 +409,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Validate service name for command injection try { validateShellSafePath($serviceName, 'service name'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker Compose service name: '.$e->getMessage(). ' Service names must not contain shell metacharacters.' ); @@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $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 (count($valueMatches[2]) > 0) { + foreach ($valueMatches[2] as $match) { + $match = str($match); if ($match->startsWith('SERVICE_')) { if ($magicEnvironments->has($match->value())) { continue; @@ -465,7 +465,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version); } - if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { $fqdn = "$fqdn$path"; @@ -738,8 +738,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -749,8 +749,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } $source = replaceLocalSource($source, $mainDirectory); - if ($isPullRequest) { + $isPreviewSuffixEnabled = $foundConfig + ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) + : true; + if ($isPullRequest && $isPreviewSuffixEnabled) { $source = addPreviewDeploymentSuffix($source, $pull_request_id); } LocalFileVolume::updateOrCreate( @@ -986,65 +989,155 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int continue; } if ($key->value() === $parsedValue->value()) { - $value = null; + // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) + // Ensure the variable exists in DB for .env generation and UI display $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $value, 'is_preview' => false, ]); + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } 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); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $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); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + $parsedKeyValue = replaceVariables($value); + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + // Add the variable to the environment using the saved DB value + $environment[$content] = $envVar->value; + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + 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 + $parsedKeyValue = replaceVariables($value); + $envVar = $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 using the saved DB value + $environment[$parsedKeyValue->value()] = $envVar->value; + + continue; + } $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, '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, - ]); } } } @@ -1205,6 +1298,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Otherwise keep empty string as-is } + // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} + // Without this, literal {{...}} strings end up in the compose environment: section, + // which takes precedence over the resolved values in the .env file (env_file:) + if (is_string($value) && str_contains($value, '{{')) { + $value = resolveSharedEnvironmentVariables($value, $resource); + } + return $value; }); } @@ -1219,21 +1319,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } 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'); + $labelUuid = $resource->uuid; + $labelNetwork = data_get($resource, 'destination.network'); if ($isPullRequest) { - $uuid = "{$resource->uuid}-{$pullRequestId}"; + $labelUuid = "{$resource->uuid}-{$pullRequestId}"; } if ($isPullRequest) { - $network = "{$resource->destination->network}-{$pullRequestId}"; + $labelNetwork = "{$resource->destination->network}-{$pullRequestId}"; } if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, + uuid: $labelUuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1243,10 +1343,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int break; case ProxyTypes::CADDY->value: $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, + network: $labelNetwork, + uuid: $labelUuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1258,9 +1358,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } else { $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, + uuid: $labelUuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1268,10 +1368,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int image: $image )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, + network: $labelNetwork, + uuid: $labelUuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1389,7 +1489,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); - } catch (\Exception $e) { + } catch (Exception $e) { // If parsing fails, keep the original docker_compose_raw unchanged ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage()); } @@ -1411,15 +1511,30 @@ function serviceParser(Service $resource): Collection return collect([]); } + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($compose); + $server = data_get($resource, 'server'); $allServices = get_service_templates(); try { $yaml = Yaml::parse($compose); - } catch (\Exception) { + } catch (Exception) { return collect([]); } $services = data_get($yaml, 'services', collect([])); + + // Clean up corrupted environment variables from previous parser bugs + // (keys starting with $ or ending with } should not exist as env var names) + $resource->environment_variables() + ->where('resourceable_type', get_class($resource)) + ->where('resourceable_id', $resource->id) + ->where(function ($q) { + $q->where('key', 'LIKE', '$%') + ->orWhere('key', 'LIKE', '%}'); + }) + ->delete(); + $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), 'networks' => collect(data_get($yaml, 'networks', [])), @@ -1451,8 +1566,8 @@ function serviceParser(Service $resource): Collection // Validate service name for command injection try { validateShellSafePath($serviceName, 'service name'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker Compose service name: '.$e->getMessage(). ' Service names must not contain shell metacharacters.' ); @@ -1478,20 +1593,25 @@ function serviceParser(Service $resource): Collection // Use image detection for non-migrated services $isDatabase = isDatabaseImage($image, $service); if ($isDatabase) { - $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); - if ($applicationFound) { - $savedService = $applicationFound; + $databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first(); + if ($databaseFound) { + $savedService = $databaseFound; } else { - $savedService = ServiceDatabase::firstOrCreate([ + $savedService = ServiceDatabase::create([ 'name' => $serviceName, 'service_id' => $resource->id, ]); } } else { - $savedService = ServiceApplication::firstOrCreate([ - 'name' => $serviceName, - 'service_id' => $resource->id, - ]); + $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); + if ($applicationFound) { + $savedService = $applicationFound; + } else { + $savedService = ServiceApplication::create([ + 'name' => $serviceName, + 'service_id' => $resource->id, + ]); + } } } // Update image if it changed @@ -1597,9 +1717,9 @@ function serviceParser(Service $resource): Collection $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 (count($valueMatches[2]) > 0) { + foreach ($valueMatches[2] as $match) { + $match = str($match); if ($match->startsWith('SERVICE_')) { if ($magicEnvironments->has($match->value())) { continue; @@ -1657,7 +1777,7 @@ function serviceParser(Service $resource): Collection // Strip scheme for environment variable values $fqdnValueForEnv = str($fqdn)->after('://')->value(); - if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { // Only add path if it's not already present (prevents duplication on subsequent parse() calls) @@ -1694,51 +1814,60 @@ function serviceParser(Service $resource): Collection } // ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port) + $fqdnKey = "SERVICE_FQDN_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}", + 'key' => $fqdnKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnv, 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); + $urlKey = "SERVICE_URL_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}", + 'key' => $urlKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); // For port-specific variables, ALSO create port-specific pairs // If template variable has port, create both URL and FQDN with port suffix if ($parsed['has_port'] && $port) { + $fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}_{$port}", + 'key' => $fqdnPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnvWithPort, 'is_preview' => false, + 'comment' => $envComments[$fqdnPortKey] ?? null, ]); + $urlPortKey = "SERVICE_URL_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}_{$port}", + 'key' => $urlPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $urlWithPort, 'is_preview' => false, + 'comment' => $envComments[$urlPortKey] ?? null, ]); } } } $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); if ($magicEnvironments->count() > 0) { - foreach ($magicEnvironments as $key => $value) { - $key = str($key); + foreach ($magicEnvironments as $magicKey => $value) { + $originalMagicKey = $magicKey; // Preserve original key for comment lookup + $key = str($magicKey); $value = replaceVariables($value); $command = parseCommandFromMagicEnvVariable($key); if ($command->value() === 'FQDN') { @@ -1762,6 +1891,8 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } + // Create FQDN variable (use firstOrCreate to avoid overwriting values + // already set by direct template declarations or updateCompose) $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1769,11 +1900,25 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $fqdn, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, + ]); + + // Also create the paired SERVICE_URL_* variable + $urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor); + $resource->environment_variables()->firstOrCreate([ + 'key' => $urlKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $url, + 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); } elseif ($command->value() === 'URL') { $urlFor = $key->after('SERVICE_URL_')->lower()->value(); $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid"); + $fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version); $envExists = $resource->environment_variables()->where('key', $key->value())->first(); // Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791) @@ -1790,6 +1935,8 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } + // Create URL variable (use firstOrCreate to avoid overwriting values + // already set by direct template declarations or updateCompose) $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1797,6 +1944,19 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, + ]); + + // Also create the paired SERVICE_FQDN_* variable + $fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor); + $resource->environment_variables()->firstOrCreate([ + 'key' => $fqdnKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $fqdn, + 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); } else { @@ -1808,6 +1968,7 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } } @@ -1964,8 +2125,8 @@ function serviceParser(Service $resource): Collection if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -1975,8 +2136,8 @@ function serviceParser(Service $resource): Collection if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); - } catch (\Exception $e) { - throw new \Exception( + } catch (Exception $e) { + throw new Exception( 'Invalid Docker volume definition (array syntax): '.$e->getMessage(). ' Please use safe path names without shell metacharacters.' ); @@ -2163,18 +2324,20 @@ function serviceParser(Service $resource): Collection return ! str($value)->startsWith('SERVICE_'); }); foreach ($normalEnvironments as $key => $value) { + $originalKey = $key; // Preserve original key for comment lookup $key = str($key); $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); if ($parsedValue->startsWith('SERVICE_')) { - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); continue; @@ -2183,65 +2346,170 @@ function serviceParser(Service $resource): Collection continue; } if ($key->value() === $parsedValue->value()) { - $value = null; + // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) + // Ensure the variable exists in DB for .env generation and UI display $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } 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); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $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); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + // Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + // Extract and create nested variables + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + // Create nested variable with its default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + // Simple nested variable without default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + // Look for more nested variables + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + // Add the variable to the environment using the saved DB value + $environment[$content] = $envVar->value; + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + 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 + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $parsedKeyValue = replaceVariables($value); + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + // Add the variable to the environment using the saved DB value + $environment[$parsedKeyValue->value()] = $envVar->value; + + continue; + } + // Variable with a default value from compose — use firstOrCreate to preserve user edits $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, ]); - // 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, - ]); } } } @@ -2307,6 +2575,13 @@ function serviceParser(Service $resource): Collection // Otherwise keep empty string as-is } + // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} + // Without this, literal {{...}} strings end up in the compose environment: section, + // which takes precedence over the resolved values in the .env file (env_file:) + if (is_string($value) && str_contains($value, '{{')) { + $value = resolveSharedEnvironmentVariables($value, $resource); + } + return $value; }); } @@ -2471,7 +2746,7 @@ function serviceParser(Service $resource): Collection } } $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); - } catch (\Exception $e) { + } catch (Exception $e) { // If parsing fails, keep the original docker_compose_raw unchanged ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage()); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ac52c0af8..ed18dfe76 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; /** @@ -108,18 +109,20 @@ function connectProxyToNetworks(Server $server) ['networks' => $networks] = collectDockerNetworksByServer($server); if ($server->isSwarm()) { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null", - "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", - "echo 'Successfully connected coolify-proxy to $network network.'", + "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null", + "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; }); } else { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null", - "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", - "echo 'Successfully connected coolify-proxy to $network network.'", + "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null", + "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to {$safe} network.'", ]; }); } @@ -140,16 +143,18 @@ function ensureProxyNetworksExist(Server $server) if ($server->isSwarm()) { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", + "echo 'Ensuring network {$safe} exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}", ]; }); } else { $commands = $networks->map(function ($network) { + $safe = escapeshellarg($network); return [ - "echo 'Ensuring network $network exists...'", - "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", + "echo 'Ensuring network {$safe} exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}", ]; }); } @@ -215,6 +220,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar } function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { + Log::info('Generating default proxy configuration', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'custom_commands_count' => count($custom_commands), + 'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown', + ]); + $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 217c82929..2544719fc 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -1,9 +1,10 @@ teams->pluck('id'); if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { - throw new \Exception('User is not part of the team that owns this server'); + throw new Exception('User is not part of the team that owns this server'); } } SshMultiplexingHelper::ensureMultiplexedConnection($server); - return resolve(PrepareCoolifyTask::class, [ - 'remoteProcessArgs' => new CoolifyTaskArgs( - server_uuid: $server->uuid, - command: $command_string, - type: $type, - type_uuid: $type_uuid, - model: $model, - ignore_errors: $ignore_errors, - call_event_on_finish: $callEventOnFinish, - call_event_data: $callEventData, - ), - ])(); + $properties = [ + 'server_uuid' => $server->uuid, + 'command' => $command_string, + 'type' => $type, + 'type_uuid' => $type_uuid, + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $server->team_id, + ]; + + $activityLog = activity() + ->withProperties($properties) + ->event($type); + + if ($model) { + $activityLog->performedOn($model); + } + + $activity = $activityLog->log('[]'); + + dispatch(new CoolifyTask( + activity: $activity, + ignore_errors: $ignore_errors, + call_event_on_finish: $callEventOnFinish, + call_event_data: $callEventData, + )); + + $activity->refresh(); + + return $activity; } function instant_scp(string $source, string $dest, Server $server, $throwError = true) { - return \App\Helpers\SshRetryHandler::retry( + return 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); @@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $ } $command_string = implode("\n", $command); - return \App\Helpers\SshRetryHandler::retry( + return SshRetryHandler::retry( function () use ($server, $command_string) { $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); $process = Process::timeout(30)->run($sshCommand); @@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool $command_string = implode("\n", $command); $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout'); - return \App\Helpers\SshRetryHandler::retry( + return SshRetryHandler::retry( function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing); $process = Process::timeout($effectiveTimeout)->run($sshCommand); @@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) if ($ignored) { // TODO: Create new exception and disable in sentry - throw new \RuntimeException($errorMessage, $exitCode); + throw new RuntimeException($errorMessage, $exitCode); } - throw new \RuntimeException($errorMessage, $exitCode); + throw new RuntimeException($errorMessage, $exitCode); } function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection @@ -194,7 +212,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d associative: true, flags: JSON_THROW_ON_ERROR ); - } catch (\JsonException $e) { + } catch (JsonException $e) { // If JSON decoding fails, try to clean up the logs and retry try { // Ensure valid UTF-8 encoding @@ -204,7 +222,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d associative: true, flags: JSON_THROW_ON_ERROR ); - } catch (\JsonException $e) { + } catch (JsonException $e) { // If it still fails, return empty collection to prevent crashes return collect([]); } @@ -275,9 +293,9 @@ function remove_iip($text) // ANSI color codes $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); - // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.) + // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.) // (protocol://user:password@host → protocol://user:@host) - $text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); + $text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); // Email addresses $text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text); @@ -353,7 +371,7 @@ function checkRequiredCommands(Server $server) } try { instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server); - } catch (\Throwable) { + } catch (Throwable) { break; } $commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 3d2b61b86..20b184a01 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -17,9 +17,123 @@ function collectRegex(string $name) { return "/{$name}\w+/"; } + +/** + * Extract content between balanced braces, handling nested braces properly. + * + * @param string $str The string to search + * @param int $startPos Position to start searching from + * @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found + */ +function extractBalancedBraceContent(string $str, int $startPos = 0): ?array +{ + // Find opening brace + if ($startPos >= strlen($str)) { + return null; + } + $openPos = strpos($str, '{', $startPos); + if ($openPos === false) { + return null; + } + + // Track depth to find matching closing brace + $depth = 1; + $pos = $openPos + 1; + $len = strlen($str); + + while ($pos < $len && $depth > 0) { + if ($str[$pos] === '{') { + $depth++; + } elseif ($str[$pos] === '}') { + $depth--; + } + $pos++; + } + + if ($depth !== 0) { + // Unbalanced braces + return null; + } + + return [ + 'content' => substr($str, $openPos + 1, $pos - $openPos - 2), + 'start' => $openPos, + 'end' => $pos - 1, + ]; +} + +/** + * Split variable expression on operators (:-, -, :?, ?) while respecting nested braces. + * + * @param string $content The content to split (without outer ${...}) + * @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found + */ +function splitOnOperatorOutsideNested(string $content): ?array +{ + $operators = [':-', '-', ':?', '?']; + $depth = 0; + $len = strlen($content); + + for ($i = 0; $i < $len; $i++) { + if ($content[$i] === '{') { + $depth++; + } elseif ($content[$i] === '}') { + $depth--; + } elseif ($depth === 0) { + // Check for operators only at depth 0 (outside nested braces) + foreach ($operators as $op) { + if (substr($content, $i, strlen($op)) === $op) { + return [ + 'variable' => substr($content, 0, $i), + 'operator' => $op, + 'default' => substr($content, $i + strlen($op)), + ]; + } + } + } + } + + return null; +} + function replaceVariables(string $variable): Stringable { - return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + // Handle ${VAR} syntax with proper brace matching + $str = str($variable); + + // Handle ${VAR} format + if ($str->startsWith('${')) { + $result = extractBalancedBraceContent($variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback to old behavior for malformed input + return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + } + + // Handle {VAR} format (from regex capture group without $) + if ($str->startsWith('{') && $str->endsWith('}')) { + return str(substr($variable, 1, -1)); + } + + // Handle {VAR format (from regex capture group, may be truncated) + if ($str->startsWith('{')) { + $result = extractBalancedBraceContent('$'.$variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback: remove { and get content before } + return $str->replaceFirst('{', '')->before('}'); + } + + // Handle bare $VAR format (no braces) + if ($str->startsWith('$')) { + return $str->replaceFirst('$', ''); + } + + return $str; } function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2173e7619..cd773f6a9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -8,6 +8,7 @@ use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -15,6 +16,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -27,8 +29,10 @@ use App\Models\User; use Carbon\CarbonImmutable; use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Process\Pool; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; @@ -48,10 +52,14 @@ use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Token\Builder; +use Livewire\Component; +use Nubs\RandomNameGenerator\All; +use Nubs\RandomNameGenerator\Alliteration; use phpseclib3\Crypt\EC; use phpseclib3\Crypt\RSA; use Poliander\Cron\CronExpression; use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; @@ -115,7 +123,7 @@ function sanitize_string(?string $input = null): ?string * @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name') * @return string The validated input (unchanged if valid) * - * @throws \Exception If dangerous characters are detected + * @throws Exception If dangerous characters are detected */ function validateShellSafePath(string $input, string $context = 'path'): string { @@ -137,7 +145,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string // Check for dangerous characters foreach ($dangerousChars as $char => $description) { if (str_contains($input, $char)) { - throw new \Exception( + throw new Exception( "Invalid {$context}: contains forbidden character '{$char}' ({$description}). ". 'Shell metacharacters are not allowed for security reasons.' ); @@ -147,6 +155,92 @@ function validateShellSafePath(string $input, string $context = 'path'): string return $input; } +/** + * Validate that a databases_to_backup input string is safe from command injection. + * + * Supports all database formats: + * - PostgreSQL/MySQL/MariaDB: "db1,db2,db3" + * - MongoDB: "db1:col1,col2|db2:col3,col4" + * + * Validates each database name AND collection name individually against shell metacharacters. + * + * @param string $input The databases_to_backup string + * @return string The validated input + * + * @throws Exception If any component contains dangerous characters + */ +function validateDatabasesBackupInput(string $input): string +{ + // Split by pipe (MongoDB multi-db separator) + $databaseEntries = explode('|', $input); + + foreach ($databaseEntries as $entry) { + $entry = trim($entry); + if ($entry === '' || $entry === 'all' || $entry === '*') { + continue; + } + + if (str_contains($entry, ':')) { + // MongoDB format: dbname:collection1,collection2 + $databaseName = str($entry)->before(':')->value(); + $collections = str($entry)->after(':')->explode(','); + + validateShellSafePath($databaseName, 'database name'); + + foreach ($collections as $collection) { + $collection = trim($collection); + if ($collection !== '') { + validateShellSafePath($collection, 'collection name'); + } + } + } else { + // Simple format: just a database name (may contain commas for non-Mongo) + $databases = explode(',', $entry); + foreach ($databases as $db) { + $db = trim($db); + if ($db !== '' && $db !== 'all' && $db !== '*') { + validateShellSafePath($db, 'database name'); + } + } + } + } + + return $input; +} + +/** + * Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD). + * + * Prevents command injection by enforcing an allowlist of characters valid for git refs. + * Valid: hex SHAs, HEAD, branch/tag names (alphanumeric, dots, hyphens, underscores, slashes). + * + * @param string $input The git ref to validate + * @param string $context Descriptive name for error messages + * @return string The validated input (trimmed) + * + * @throws Exception If the input contains disallowed characters + */ +function validateGitRef(string $input, string $context = 'git ref'): string +{ + $input = trim($input); + + if ($input === '' || $input === 'HEAD') { + return $input; + } + + // Must not start with a hyphen (git flag injection) + if (str_starts_with($input, '-')) { + throw new Exception("Invalid {$context}: must not start with a hyphen."); + } + + // Allow only alphanumeric characters, dots, hyphens, underscores, and slashes + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) { + throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); + } + + return $input; +} + function generate_readme_file(string $name, string $updated_at): string { $name = sanitize_string($name); @@ -167,6 +261,10 @@ function currentTeam() function showBoarding(): bool { + if (isDev()) { + return false; + } + if (Auth::user()?->isMember()) { return false; } @@ -191,7 +289,7 @@ function refreshSession(?Team $team = null): void }); session(['currentTeam' => $team]); } -function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null) +function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null) { if ($error instanceof TooManyRequestsException) { if (isset($livewire)) { @@ -208,7 +306,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n return 'Duplicate entry found. Please use a different name.'; } - if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { + if ($error instanceof ModelNotFoundException) { abort(404); } @@ -238,7 +336,7 @@ function get_latest_sentinel_version(): string $versions = $response->json(); return data_get($versions, 'coolify.sentinel.version'); - } catch (\Throwable) { + } catch (Throwable) { return '0.0.0'; } } @@ -248,7 +346,7 @@ function get_latest_version_of_coolify(): string $versions = get_versions_data(); return data_get($versions, 'coolify.v4.version', '0.0.0'); - } catch (\Throwable $e) { + } catch (Throwable $e) { return '0.0.0'; } @@ -256,9 +354,9 @@ function get_latest_version_of_coolify(): string function generate_random_name(?string $cuid = null): string { - $generator = new \Nubs\RandomNameGenerator\All( + $generator = new All( [ - new \Nubs\RandomNameGenerator\Alliteration, + new Alliteration, ] ); if (is_null($cuid)) { @@ -301,7 +399,18 @@ function generate_application_name(string $git_repository, string $git_branch, ? $cuid = new Cuid2; } - return Str::kebab("$git_repository:$git_branch-$cuid"); + $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; + + $name = Str::kebab("$repo_name:$git_branch-$cuid"); + + // Strip characters not allowed by NAME_PATTERN + $name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name); + + if (empty($name) || mb_strlen($name) < 3) { + return generate_random_name($cuid); + } + + return $name; } /** @@ -346,7 +455,7 @@ function getFqdnWithoutPort(string $fqdn) $path = $url->getPath(); return "$scheme://$host$path"; - } catch (\Throwable) { + } catch (Throwable) { return $fqdn; } } @@ -376,13 +485,13 @@ function base_url(bool $withPort = true): string } if ($settings->public_ipv6) { if ($withPort) { - return "http://$settings->public_ipv6:$port"; + return "http://[$settings->public_ipv6]:$port"; } - return "http://$settings->public_ipv6"; + return "http://[$settings->public_ipv6]"; } - return url('/'); + return config('app.url'); } function isSubscribed() @@ -428,6 +537,36 @@ function validate_cron_expression($expression_to_validate): bool return $isValid; } +/** + * Determine if a cron schedule should run now, with deduplication. + * + * Uses getPreviousRunDate() + last-dispatch tracking to be resilient to queue delays. + * Even if the job runs minutes late, it still catches the missed cron window. + * Without a dedupKey, falls back to a simple isDue() check. + */ +function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool +{ + $cron = new Cron\CronExpression($frequency); + $executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone); + + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + $lastDispatched = Cache::get($dedupKey); + + $shouldFire = $lastDispatched === null + ? $cron->isDue($executionTime) + : $previousDue->gt(Carbon::parse($lastDispatched)); + + // Always write: seeds on first miss, refreshes on dispatch. + // 30-day static TTL covers all intervals; orphan keys self-clean. + Cache::put($dedupKey, ($shouldFire ? $executionTime : $previousDue)->toIso8601String(), 2592000); + + return $shouldFire; +} + function validate_timezone(string $timezone): bool { return in_array($timezone, timezone_identifiers_list()); @@ -444,19 +583,286 @@ function parseEnvFormatToArray($env_file_contents) $equals_pos = strpos($line, '='); if ($equals_pos !== false) { $key = substr($line, 0, $equals_pos); - $value = substr($line, $equals_pos + 1); - if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') { - $value = substr($value, 1, -1); - } elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") { - $value = substr($value, 1, -1); + $value_and_comment = substr($line, $equals_pos + 1); + $comment = null; + $remainder = ''; + + // Check if value starts with quotes + $firstChar = $value_and_comment[0] ?? ''; + $isDoubleQuoted = $firstChar === '"'; + $isSingleQuoted = $firstChar === "'"; + + if ($isDoubleQuoted) { + // Find the closing double quote + $closingPos = strpos($value_and_comment, '"', 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } elseif ($isSingleQuoted) { + // Find the closing single quote + $closingPos = strpos($value_and_comment, "'", 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } else { + // Unquoted value - strip inline comments + // Only treat # as comment if preceded by whitespace + if (preg_match('/\s+#/', $value_and_comment, $matches, PREG_OFFSET_CAPTURE)) { + // Found whitespace followed by #, extract comment + $remainder = substr($value_and_comment, $matches[0][1]); + $value = substr($value_and_comment, 0, $matches[0][1]); + $value = rtrim($value); + } else { + $value = $value_and_comment; + } } - $env_array[$key] = $value; + + // Extract comment from remainder (if any) + if ($remainder !== '') { + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + // Extract everything after the # and trim + $comment = substr($remainder, $hashPos + 1); + $comment = trim($comment); + // Set to null if empty after trimming + if ($comment === '') { + $comment = null; + } + } + } + + $env_array[$key] = [ + 'value' => $value, + 'comment' => $comment, + ]; } } return $env_array; } +/** + * Extract inline comments from environment variables in raw docker-compose YAML. + * + * Parses raw docker-compose YAML to extract inline comments from environment sections. + * Standard YAML parsers discard comments, so this pre-processes the raw text. + * + * Handles both formats: + * - Map format: `KEY: "value" # comment` or `KEY: value # comment` + * - Array format: `- KEY=value # comment` + * + * @param string $rawYaml The raw docker-compose.yml content + * @return array Map of environment variable keys to their inline comments + */ +function extractYamlEnvironmentComments(string $rawYaml): array +{ + $comments = []; + $lines = explode("\n", $rawYaml); + $inEnvironmentBlock = false; + $environmentIndent = 0; + + foreach ($lines as $line) { + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Calculate current line's indentation (number of leading spaces) + $currentIndent = strlen($line) - strlen(ltrim($line)); + + // Check if this line starts an environment block + if (preg_match('/^(\s*)environment\s*:\s*$/', $line, $matches)) { + $inEnvironmentBlock = true; + $environmentIndent = strlen($matches[1]); + + continue; + } + + // Check if this line starts an environment block with inline content (rare but possible) + if (preg_match('/^(\s*)environment\s*:\s*\{/', $line)) { + // Inline object format - not supported for comment extraction + continue; + } + + // If we're in an environment block, check if we've exited it + if ($inEnvironmentBlock) { + // If we hit a line with same or less indentation that's not empty, we've left the block + // Unless it's a continuation of the environment block + $trimmedLine = ltrim($line); + + // Check if this is a new top-level key (same indent as 'environment:' or less) + if ($currentIndent <= $environmentIndent && ! str_starts_with($trimmedLine, '-') && ! str_starts_with($trimmedLine, '#')) { + // Check if it looks like a YAML key (contains : not inside quotes) + if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*:/', $trimmedLine)) { + $inEnvironmentBlock = false; + + continue; + } + } + + // Skip comment-only lines + if (str_starts_with($trimmedLine, '#')) { + continue; + } + + // Try to extract environment variable and comment from this line + $extracted = extractEnvVarCommentFromYamlLine($trimmedLine); + if ($extracted !== null && $extracted['comment'] !== null) { + $comments[$extracted['key']] = $extracted['comment']; + } + } + } + + return $comments; +} + +/** + * Extract environment variable key and inline comment from a single YAML line. + * + * @param string $line A trimmed line from the environment section + * @return array|null Array with 'key' and 'comment', or null if not an env var line + */ +function extractEnvVarCommentFromYamlLine(string $line): ?array +{ + $key = null; + $comment = null; + + // Handle array format: `- KEY=value # comment` or `- KEY # comment` + if (str_starts_with($line, '-')) { + $content = ltrim(substr($line, 1)); + + // Check for KEY=value format + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)/', $content, $keyMatch)) { + $key = $keyMatch[1]; + // Find comment - need to handle quoted values + $comment = extractCommentAfterValue($content); + } + } + // Handle map format: `KEY: "value" # comment` or `KEY: value # comment` + elseif (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:/', $line, $keyMatch)) { + $key = $keyMatch[1]; + // Get everything after the key and colon + $afterKey = substr($line, strlen($keyMatch[0])); + $comment = extractCommentAfterValue($afterKey); + } + + if ($key === null) { + return null; + } + + return [ + 'key' => $key, + 'comment' => $comment, + ]; +} + +/** + * Extract inline comment from a value portion of a YAML line. + * + * Handles quoted values (where # inside quotes is not a comment). + * + * @param string $valueAndComment The value portion (may include comment) + * @return string|null The comment text, or null if no comment + */ +function extractCommentAfterValue(string $valueAndComment): ?string +{ + $valueAndComment = ltrim($valueAndComment); + + if ($valueAndComment === '') { + return null; + } + + $firstChar = $valueAndComment[0] ?? ''; + + // Handle case where value is empty and line starts directly with comment + // e.g., `KEY: # comment` becomes `# comment` after ltrim + if ($firstChar === '#') { + $comment = trim(substr($valueAndComment, 1)); + + return $comment !== '' ? $comment : null; + } + + // Handle double-quoted value + if ($firstChar === '"') { + // Find closing quote (handle escaped quotes) + $pos = 1; + $len = strlen($valueAndComment); + while ($pos < $len) { + if ($valueAndComment[$pos] === '\\' && $pos + 1 < $len) { + $pos += 2; // Skip escaped character + + continue; + } + if ($valueAndComment[$pos] === '"') { + // Found closing quote + $remainder = substr($valueAndComment, $pos + 1); + + return extractCommentFromRemainder($remainder); + } + $pos++; + } + + // No closing quote found + return null; + } + + // Handle single-quoted value + if ($firstChar === "'") { + // Find closing quote (single quotes don't have escapes in YAML) + $closingPos = strpos($valueAndComment, "'", 1); + if ($closingPos !== false) { + $remainder = substr($valueAndComment, $closingPos + 1); + + return extractCommentFromRemainder($remainder); + } + + // No closing quote found + return null; + } + + // Unquoted value - find # that's preceded by whitespace + // Be careful not to match # at the start of a value like color codes + if (preg_match('/\s+#\s*(.*)$/', $valueAndComment, $matches)) { + $comment = trim($matches[1]); + + return $comment !== '' ? $comment : null; + } + + return null; +} + +/** + * Extract comment from the remainder of a line after a quoted value. + * + * @param string $remainder Text after the closing quote + * @return string|null The comment text, or null if no comment + */ +function extractCommentFromRemainder(string $remainder): ?string +{ + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + $comment = trim(substr($remainder, $hashPos + 1)); + + return $comment !== '' ? $comment : null; + } + + return null; +} + function data_get_str($data, $key, $default = null): Stringable { $str = data_get($data, $key, $default) ?? $default; @@ -533,7 +939,7 @@ function get_service_templates(bool $force = false): Collection $services = $response->json(); return collect($services); - } catch (\Throwable) { + } catch (Throwable) { $services = File::get(base_path('templates/'.config('constants.services.file_name'))); return collect(json_decode($services))->sortKeys(); @@ -556,7 +962,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) } // ServiceDatabase has a different relationship path: service->environment->project->team_id - if ($resource instanceof \App\Models\ServiceDatabase) { + if ($resource instanceof ServiceDatabase) { if ($resource->service?->environment?->project?->team_id === $teamId) { return $resource; } @@ -682,7 +1088,7 @@ function generateGitManualWebhook($resource, $type) if ($resource->source_id !== 0 && ! is_null($resource->source_id)) { return null; } - if ($resource->getMorphClass() === \App\Models\Application::class) { + if ($resource->getMorphClass() === Application::class) { $baseUrl = base_url(); return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual"; @@ -703,11 +1109,11 @@ function sanitizeLogsForExport(string $text): string function getTopLevelNetworks(Service|Application $resource) { - if ($resource->getMorphClass() === \App\Models\Service::class) { + if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { + } catch (Exception $e) { // If the docker-compose.yml file is not valid, we will return the network name as the key $topLevelNetworks = collect([ $resource->uuid => [ @@ -770,10 +1176,10 @@ function getTopLevelNetworks(Service|Application $resource) return $topLevelNetworks->keys(); } - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { + } catch (Exception $e) { // If the docker-compose.yml file is not valid, we will return the network name as the key $topLevelNetworks = collect([ $resource->uuid => [ @@ -1080,7 +1486,7 @@ function validateDNSEntry(string $fqdn, Server $server) $ip = $server->ip; } $found_matching_ip = false; - $type = \PurplePixie\PhpDns\DNSTypes::NAME_A; + $type = DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { $query = new DNSQuery($dns_server); @@ -1101,7 +1507,7 @@ function validateDNSEntry(string $fqdn, Server $server) } } } - } catch (\Exception) { + } catch (Exception) { } } @@ -1145,24 +1551,48 @@ function checkIPAgainstAllowlist($ip, $allowlist) } $mask = (int) $mask; + $isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6Subnet ? 128 : 32; - // Validate mask - if ($mask < 0 || $mask > 32) { + // Validate mask for address family + if ($mask < 0 || $mask > $maxMask) { continue; } - // Calculate network addresses - $ip_long = ip2long($ip); - $subnet_long = ip2long($subnet); + if ($isIpv6Subnet) { + // IPv6 CIDR matching using binary string comparison + $ipBin = inet_pton($ip); + $subnetBin = inet_pton($subnet); - if ($ip_long === false || $subnet_long === false) { - continue; - } + if ($ipBin === false || $subnetBin === false) { + continue; + } - $mask_long = ~((1 << (32 - $mask)) - 1); + // Build a 128-bit mask from $mask prefix bits + $maskBin = str_repeat("\xff", (int) ($mask / 8)); + $remainder = $mask % 8; + if ($remainder > 0) { + $maskBin .= chr(0xFF & (0xFF << (8 - $remainder))); + } + $maskBin = str_pad($maskBin, 16, "\x00"); - if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { - return true; + if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) { + return true; + } + } else { + // IPv4 CIDR matching + $ip_long = ip2long($ip); + $subnet_long = ip2long($subnet); + + if ($ip_long === false || $subnet_long === false) { + continue; + } + + $mask_long = ~((1 << (32 - $mask)) - 1); + + if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { + return true; + } } } else { // Special case: 0.0.0.0 means allow all @@ -1180,6 +1610,67 @@ function checkIPAgainstAllowlist($ip, $allowlist) return false; } +function deduplicateAllowlist(array $entries): array +{ + if (count($entries) <= 1) { + return array_values($entries); + } + + // Normalize each entry into [original, ip, mask] + $parsed = []; + foreach ($entries as $entry) { + $entry = trim($entry); + if (empty($entry)) { + continue; + } + + if ($entry === '0.0.0.0') { + // Special case: bare 0.0.0.0 means "allow all" — treat as /0 + $parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0]; + } elseif (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask]; + } else { + $ip = $entry; + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32]; + } + } + + $count = count($parsed); + $redundant = array_fill(0, $count, false); + + for ($i = 0; $i < $count; $i++) { + if ($redundant[$i]) { + continue; + } + + for ($j = 0; $j < $count; $j++) { + if ($i === $j || $redundant[$j]) { + continue; + } + + // Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask + // AND $j's network IP falls within $i's CIDR range + if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) { + $cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask']; + if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) { + $redundant[$j] = true; + } + } + } + } + + $result = []; + for ($i = 0; $i < $count; $i++) { + if (! $redundant[$i]) { + $result[] = $parsed[$i]['original']; + } + } + + return $result; +} + function get_public_ips() { try { @@ -1198,7 +1689,7 @@ function get_public_ips() } InstanceSettings::get()->update(['public_ipv4' => $ipv4]); } - } catch (\Exception $e) { + } catch (Exception $e) { echo "Error: {$e->getMessage()}\n"; } try { @@ -1213,7 +1704,7 @@ function get_public_ips() } InstanceSettings::get()->update(['public_ipv6' => $ipv6]); } - } catch (\Throwable $e) { + } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; } } @@ -1311,12 +1802,15 @@ function customApiValidator(Collection|array $item, array $rules) } function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null) { - if ($resource->getMorphClass() === \App\Models\Service::class) { + if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($resource->docker_compose_raw); + try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); } $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); @@ -1344,7 +1838,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) { + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { // Workarounds for beta users. if ($serviceName === 'registry') { $tempServiceName = 'docker-registry'; @@ -1690,6 +2184,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $key = str($variableName); $value = str($variable); } + // Preserve original key for comment lookup before $key might be reassigned + $originalKey = $key->value(); if ($key->startsWith('SERVICE_FQDN')) { if ($isNew || $savedService->fqdn === null) { $name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower(); @@ -1743,6 +2239,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } // Caddy needs exact port in some cases. @@ -1822,6 +2319,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } if (! $isDatabase) { @@ -1860,6 +2358,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -1898,6 +2397,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -2074,10 +2574,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } else { return collect([]); } - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception) { + } catch (Exception) { return; } $server = $resource->destination->server; @@ -2839,7 +3339,7 @@ function isAssociativeArray($array) } if (! is_array($array)) { - throw new \InvalidArgumentException('Input must be an array or a Collection.'); + throw new InvalidArgumentException('Input must be an array or a Collection.'); } if ($array === []) { @@ -2955,7 +3455,7 @@ function wireNavigate(): string // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : ''; - } catch (\Exception $e) { + } catch (Exception $e) { return 'wire:navigate.hover'; } } @@ -2964,13 +3464,13 @@ function wireNavigate(): string * Redirect to a named route with SPA navigation support. * Automatically uses wire:navigate when is_wire_navigate_enabled is true. */ -function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed +function redirectRoute(Component $component, string $name, array $parameters = []): mixed { $navigate = true; try { $navigate = instanceSettings()->is_wire_navigate_enabled ?? true; - } catch (\Exception $e) { + } catch (Exception $e) { $navigate = true; } @@ -3012,7 +3512,7 @@ function loadConfigFromGit(string $repository, string $branch, string $base_dire ]); try { return instant_remote_process($commands, $server); - } catch (\Exception) { + } catch (Exception) { // continue } } @@ -3124,7 +3624,7 @@ function defaultNginxConfiguration(string $type = 'static'): string } } -function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array +function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array { $repository = $gitRepository; $providerInfo = [ @@ -3143,7 +3643,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp // If this happens, the user may have provided an HTTP URL when they needed an SSH one // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { - case \App\Models\GithubApp::class: + case GithubApp::class: + case GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; @@ -3421,10 +3922,10 @@ function shouldSkipPasswordConfirmation(): bool * - User has no password (OAuth users) * * @param mixed $password The password to verify (may be array if skipped by frontend) - * @param \Livewire\Component|null $component Optional Livewire component to add errors to + * @param Component|null $component Optional Livewire component to add errors to * @return bool True if verification passed (or skipped), false if password is incorrect */ -function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool +function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool { // Skip if password confirmation should be skipped if (shouldSkipPasswordConfirmation()) { @@ -3443,6 +3944,58 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon return true; } +/** + * Extract hard-coded environment variables from docker-compose YAML. + * + * @param string $dockerComposeRaw Raw YAML content + * @return Collection Collection of arrays with: key, value, comment, service_name + */ +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection +{ + if (blank($dockerComposeRaw)) { + return collect([]); + } + + try { + $yaml = Yaml::parse($dockerComposeRaw); + } catch (Exception $e) { + // Malformed YAML - return empty collection + return collect([]); + } + + $services = data_get($yaml, 'services', []); + if (empty($services)) { + return collect([]); + } + + // Extract inline comments from raw YAML + $envComments = extractYamlEnvironmentComments($dockerComposeRaw); + + $hardcodedVars = collect([]); + + foreach ($services as $serviceName => $service) { + $environment = collect(data_get($service, 'environment', [])); + + if ($environment->isEmpty()) { + continue; + } + + // Convert environment variables to key-value format + $environment = convertToKeyValueCollection($environment); + + foreach ($environment as $key => $value) { + $hardcodedVars->push([ + 'key' => $key, + 'value' => $value, + 'comment' => $envComments[$key] ?? null, + 'service_name' => $serviceName, + ]); + } + } + + return $hardcodedVars; +} + /** * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. * This preserves the visual shape of the data better than simple averaging. @@ -3520,3 +4073,49 @@ function downsampleLTTB(array $data, int $threshold): array return $sampled; } + +/** + * Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}. + * + * This is the canonical implementation used by both EnvironmentVariable::realValue and the compose parsers + * to ensure shared variable references are replaced with their actual values. + */ +function resolveSharedEnvironmentVariables(?string $value, $resource): ?string +{ + if (is_null($value) || $value === '' || is_null($resource)) { + return $value; + } + $value = trim($value); + $sharedEnvsFound = str($value)->matchAll('/{{(.*?)}}/'); + if ($sharedEnvsFound->isEmpty()) { + return $value; + } + foreach ($sharedEnvsFound as $sharedEnv) { + $type = str($sharedEnv)->trim()->match('/(.*?)\./'); + if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { + continue; + } + $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); + $id = null; + if ($type->value() === 'environment') { + $id = $resource->environment->id; + } elseif ($type->value() === 'project') { + $id = $resource->environment->project->id; + } elseif ($type->value() === 'team') { + $id = $resource->team()->id; + } + if (is_null($id)) { + continue; + } + $found = SharedEnvironmentVariable::where('type', $type) + ->where('key', $variable) + ->where('team_id', $resource->team()->id) + ->where("{$type}_id", $id) + ->first(); + if ($found) { + $value = str($value)->replace("{{{$sharedEnv}}}", $found->value); + } + } + + return str($value)->value(); +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 4b84fb7f6..709af854a 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts() 'login', 'logout', 'force-password-reset', + 'two-factor-challenge', 'livewire/update', 'admin', ]; @@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts() 'logout', 'verify', 'force-password-reset', + 'two-factor-challenge', 'livewire/update', ]; } diff --git a/composer.json b/composer.json index 1c36df1ea..e2b16b31b 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/fortify": "^1.34.0", "laravel/framework": "^12.49.0", "laravel/horizon": "^5.43.0", + "laravel/nightwatch": "^1.24", "laravel/pail": "^1.2.4", "laravel/prompts": "^0.3.11|^0.3.11|^0.3.11", "laravel/sanctum": "^4.3.0", @@ -54,7 +55,7 @@ "stevebauman/purify": "^6.3.1", "stripe/stripe-php": "^16.6.0", "symfony/yaml": "^7.4.1", - "visus/cuid2": "^4.1.0", + "visus/cuid2": "^6.0.0", "yosymfony/toml": "^1.0.4", "zircote/swagger-php": "^5.8.0" }, @@ -69,6 +70,7 @@ "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.8.3", "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin-browser": "^4.2", "phpstan/phpstan": "^2.1.38", "rector/rector": "^2.3.5", "serversideup/spin": "^3.1.1", diff --git a/composer.lock b/composer.lock index 708382500..2f27235f5 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": "d22beb5f7db243339d029a288bdfe33e", + "content-hash": "40bddea995c1744e4aec517263109a2f", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.26", + "version": "3.374.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94" + "reference": "67b6b6210af47319c74c5666388d71bc1bc58276" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ad0916c6595d98f9052f60e1d7204f4740369e94", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67b6b6210af47319c74c5666388d71bc1bc58276", + "reference": "67b6b6210af47319c74c5666388d71bc1bc58276", "shasum": "" }, "require": { @@ -92,12 +92,12 @@ "aws/aws-php-sns-message-validator": "~1.0", "behat/behat": "~3.0", "composer/composer": "^2.7.8", - "dms/phpunit-arraysubset-asserts": "^0.4.0", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^9.6", + "phpunit/phpunit": "^10.0", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", @@ -135,11 +135,11 @@ "authors": [ { "name": "Amazon Web Services", - "homepage": "http://aws.amazon.com" + "homepage": "https://aws.amazon.com" } ], "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "http://aws.amazon.com/sdkforphp", + "homepage": "https://aws.amazon.com/sdk-for-php", "keywords": [ "amazon", "aws", @@ -153,22 +153,22 @@ "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.369.26" + "source": "https://github.com/aws/aws-sdk-php/tree/3.374.2" }, - "time": "2026-02-03T19:16:42+00:00" + "time": "2026-03-27T18:05:55+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.3", + "version": "v3.0.4", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" + "reference": "3feed0e212b8412cc5d2612706744789b0615824" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", + "reference": "3feed0e212b8412cc5d2612706744789b0615824", "shasum": "" }, "require": { @@ -208,22 +208,22 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" }, - "time": "2025-11-19T17:15:36+00:00" + "time": "2026-03-16T01:01:30+00:00" }, { "name": "brick/math", - "version": "0.14.5", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/618a8077b3c326045e10d5788ed713b341fcfe40", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -262,7 +262,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.5" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -270,7 +270,7 @@ "type": "github" } ], - "time": "2026-02-03T18:06:51+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -343,27 +343,27 @@ }, { "name": "danharrin/livewire-rate-limiting", - "version": "v2.1.0", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/danharrin/livewire-rate-limiting.git", - "reference": "14dde653a9ae8f38af07a0ba4921dc046235e1a0" + "reference": "c03e649220089f6e5a52d422e24e3f98c73e456d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/14dde653a9ae8f38af07a0ba4921dc046235e1a0", - "reference": "14dde653a9ae8f38af07a0ba4921dc046235e1a0", + "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/c03e649220089f6e5a52d422e24e3f98c73e456d", + "reference": "c03e649220089f6e5a52d422e24e3f98c73e456d", "shasum": "" }, "require": { - "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0" }, "require-dev": { "livewire/livewire": "^3.0", "livewire/volt": "^1.3", - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "phpunit/phpunit": "^9.0|^10.0|^11.5.3" + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.0|^10.0|^11.5.3|^12.5.12" }, "type": "library", "autoload": { @@ -393,7 +393,7 @@ "type": "github" } ], - "time": "2025-02-21T08:52:11+00:00" + "time": "2026-03-16T11:29:23+00:00" }, { "name": "dasprid/enum", @@ -522,16 +522,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.1", + "version": "4.4.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" + "reference": "61e730f1658814821a85f2402c945f3883407dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", "shasum": "" }, "require": { @@ -547,9 +547,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -608,7 +608,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.1" + "source": "https://github.com/doctrine/dbal/tree/4.4.3" }, "funding": [ { @@ -624,33 +624,33 @@ "type": "tidelift" } ], - "time": "2025-12-04T10:11:03+00:00" + "time": "2026-03-20T08:52:12+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -670,9 +670,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -1035,16 +1035,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.2", + "version": "v7.0.3", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", "shasum": "" }, "require": { @@ -1092,9 +1092,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2026-02-25T22:16:40+00:00" }, { "name": "fruitcake/php-cors", @@ -1440,16 +1440,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -1465,6 +1465,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -1536,7 +1537,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -1552,7 +1553,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1702,28 +1703,28 @@ }, { "name": "laravel/fortify", - "version": "v1.34.0", + "version": "v1.36.2", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "c322715f2786210a722ed56966f7c9877b653b25" + "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/c322715f2786210a722ed56966f7c9877b653b25", - "reference": "c322715f2786210a722ed56966f7c9877b653b25", + "url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9", + "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "pragmarx/google2fa": "^9.0", - "symfony/console": "^6.0|^7.0" + "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1761,20 +1762,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-01-26T10:23:19+00:00" + "time": "2026-03-20T20:13:51+00:00" }, { "name": "laravel/framework", - "version": "v12.49.0", + "version": "v12.55.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5" + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/4bde4530545111d8bdd1de6f545fa8824039fcb5", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5", + "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33", + "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33", "shasum": "" }, "require": { @@ -1795,7 +1796,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1890,7 +1891,7 @@ "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1.41", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0|^1.0", @@ -1983,40 +1984,41 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-28T03:40:49+00:00" + "time": "2026-03-18T14:28:59+00:00" }, { "name": "laravel/horizon", - "version": "v5.43.0", + "version": "v5.45.4", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de" + "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2a04285ba83915511afbe987cbfedafdc27fd2de", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de", + "url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6", + "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6", "shasum": "" }, "require": { "ext-json": "*", "ext-pcntl": "*", "ext-posix": "*", - "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", - "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", "nesbot/carbon": "^2.17|^3.0", "php": "^8.0", "ramsey/uuid": "^4.0", - "symfony/console": "^6.0|^7.0", - "symfony/error-handler": "^6.0|^7.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", "symfony/polyfill-php83": "^1.28", - "symfony/process": "^6.0|^7.0" + "symfony/process": "^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0", "phpstan/phpstan": "^1.10|^2.0", "predis/predis": "^1.1|^2.0|^3.0" }, @@ -2060,43 +2062,138 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.43.0" + "source": "https://github.com/laravel/horizon/tree/v5.45.4" }, - "time": "2026-01-15T15:10:56+00:00" + "time": "2026-03-18T14:14:59+00:00" }, { - "name": "laravel/pail", - "version": "v1.2.4", + "name": "laravel/nightwatch", + "version": "v1.24.4", "source": { "type": "git", - "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "url": "https://github.com/laravel/nightwatch.git", + "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8", + "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "guzzlehttp/promises": "^2.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", + "monolog/monolog": "^3.6", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.2", + "psr/http-message": "^1.0|^2.0", + "psr/log": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.0|^7.0|^8.0", + "symfony/polyfill-php84": "^1.29" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.349", + "ext-pcntl": "*", + "ext-pdo": "*", + "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/psr7": "^2.0", + "laravel/horizon": "^5.4", + "laravel/pint": "1.21.0", + "laravel/vapor-core": "^2.38.2", + "livewire/livewire": "^2.0|^3.0", + "mockery/mockery": "^1.0", + "mongodb/laravel-mongodb": "^4.0|^5.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "orchestra/testbench-core": "^8.0|^9.0|^10.0", + "orchestra/workbench": "^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0", + "singlestoredb/singlestoredb-laravel": "^1.0|^2.0", + "spatie/laravel-ignition": "^2.0", + "symfony/mailer": "^6.0|^7.0|^8.0", + "symfony/mime": "^6.0|^7.0|^8.0", + "symfony/var-dumper": "^6.0|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Nightwatch": "Laravel\\Nightwatch\\Facades\\Nightwatch" + }, + "providers": [ + "Laravel\\Nightwatch\\NightwatchServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "agent/helpers.php" + ], + "psr-4": { + "Laravel\\Nightwatch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The official Laravel Nightwatch package.", + "homepage": "https://nightwatch.laravel.com", + "keywords": [ + "Insights", + "laravel", + "monitoring" + ], + "support": { + "docs": "https://nightwatch.laravel.com/docs", + "issues": "https://github.com/laravel/nightwatch/issues", + "source": "https://github.com/laravel/nightwatch" + }, + "time": "2026-03-18T23:25:05+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -2141,34 +2238,34 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.11", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -2198,36 +2295,36 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.11" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-01-27T02:55:06+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -2263,31 +2360,90 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-22T22:27:01+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { - "name": "laravel/serializable-closure", - "version": "v2.0.8", + "name": "laravel/sentinel", + "version": "v1.0.1", "source": { "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "url": "https://github.com/laravel/sentinel.git", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/pint": "^1.27", + "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^2.1.33" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sentinel\\SentinelServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sentinel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "mior@laravel.com" + } + ], + "support": { + "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + }, + "time": "2026-02-12T13:32:54+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -2324,36 +2480,36 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.2", + "version": "v5.26.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" + "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "url": "https://api.github.com/repos/laravel/socialite/zipball/1d26f0c653a5f0e88859f4197830a29fe0cc59d0", + "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0", "shasum": "" }, "require": { "ext-json": "*", "firebase/php-jwt": "^6.4|^7.0", "guzzlehttp/guzzle": "^6.0|^7.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "league/oauth1-client": "^1.11", "php": "^7.2|^8.0", "phpseclib/phpseclib": "^3.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.12.23", "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" }, @@ -2396,20 +2552,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-01-10T16:07:28+00:00" + "time": "2026-03-24T18:37:47+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -2460,35 +2616,35 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "laravel/ui", - "version": "v4.6.1", + "version": "v4.6.3", "source": { "type": "git", "url": "https://github.com/laravel/ui.git", - "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88" + "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/ui/zipball/7d6ffa38d79f19c9b3e70a751a9af845e8f41d88", - "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88", + "url": "https://api.github.com/repos/laravel/ui/zipball/ff27db15416c1ed8ad9848f5692e47595dd5de27", + "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27", "shasum": "" }, "require": { - "illuminate/console": "^9.21|^10.0|^11.0|^12.0", - "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", - "illuminate/validation": "^9.21|^10.0|^11.0|^12.0", + "illuminate/console": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^9.21|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0", - "phpunit/phpunit": "^9.3|^10.4|^11.5" + "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.3|^10.4|^11.5|^12.5|^13.0" }, "type": "library", "extra": { @@ -2523,9 +2679,9 @@ "ui" ], "support": { - "source": "https://github.com/laravel/ui/tree/v4.6.1" + "source": "https://github.com/laravel/ui/tree/v4.6.3" }, - "time": "2025-01-28T15:15:29+00:00" + "time": "2026-03-17T13:41:52+00:00" }, { "name": "lcobucci/jwt", @@ -2602,16 +2758,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -2636,9 +2792,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -2705,7 +2861,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -2791,16 +2947,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -2868,22 +3024,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f" + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/e36a2bc60b06332c92e4435047797ded352b446f", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", "shasum": "" }, "require": { @@ -2923,9 +3079,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-02-25T16:46:44+00:00" }, { "name": "league/flysystem-local", @@ -2978,16 +3134,16 @@ }, { "name": "league/flysystem-sftp-v3", - "version": "3.31.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-sftp-v3.git", - "reference": "f01dd8d66e98b20608846963cc790c2b698e8b03" + "reference": "34ff5ef0f841add92e2b902c1005f72135b03646" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/f01dd8d66e98b20608846963cc790c2b698e8b03", - "reference": "f01dd8d66e98b20608846963cc790c2b698e8b03", + "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/34ff5ef0f841add92e2b902c1005f72135b03646", + "reference": "34ff5ef0f841add92e2b902c1005f72135b03646", "shasum": "" }, "require": { @@ -3021,9 +3177,9 @@ "sftp" ], "support": { - "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.33.0" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-03-20T13:22:31+00:00" }, { "name": "league/mime-type-detection", @@ -3159,20 +3315,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -3245,7 +3401,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -3253,20 +3409,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -3329,7 +3485,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -3337,40 +3493,40 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.8", + "version": "v3.7.11", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607" + "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/06ec7e8cd61bb01739b8f26396db6fe73b7e0607", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607", + "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", "shasum": "" }, "require": { - "illuminate/database": "^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", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", "league/mime-type-detection": "^1.9", "php": "^8.1", - "symfony/console": "^6.0|^7.0", - "symfony/http-kernel": "^6.2|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" }, "require-dev": { "calebporzio/sushi": "^2.1", - "laravel/framework": "^10.15.0|^11.0|^12.0", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.3.1", - "orchestra/testbench": "^8.21.0|^9.0|^10.0", - "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", - "phpunit/phpunit": "^10.4|^11.5", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", "psy/psysh": "^0.11.22|^0.12" }, "type": "library", @@ -3405,7 +3561,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.8" + "source": "https://github.com/livewire/livewire/tree/v3.7.11" }, "funding": [ { @@ -3413,7 +3569,7 @@ "type": "github" } ], - "time": "2026-02-03T02:57:56+00:00" + "time": "2026-02-26T00:58:19+00:00" }, { "name": "log1x/laravel-webfonts", @@ -3479,27 +3635,27 @@ }, { "name": "lorisleiva/laravel-actions", - "version": "v2.9.1", + "version": "v2.10.1", "source": { "type": "git", "url": "https://github.com/lorisleiva/laravel-actions.git", - "reference": "11c2531366ca8bd5efcd0afc9e8047e7999926ff" + "reference": "1cb9fd448c655ae90ac93c77be0c10cb57cf27d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/11c2531366ca8bd5efcd0afc9e8047e7999926ff", - "reference": "11c2531366ca8bd5efcd0afc9e8047e7999926ff", + "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/1cb9fd448c655ae90ac93c77be0c10cb57cf27d5", + "reference": "1cb9fd448c655ae90ac93c77be0c10cb57cf27d5", "shasum": "" }, "require": { - "illuminate/contracts": "^10.0|^11.0|^12.0", - "lorisleiva/lody": "^0.6", - "php": "^8.1" + "illuminate/contracts": "^11.0|^12.0|^13.0", + "lorisleiva/lody": "^0.7", + "php": "^8.2" }, "require-dev": { - "orchestra/testbench": "^10.0", - "pestphp/pest": "^2.34|^3.0", - "phpunit/phpunit": "^10.5|^11.5" + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpunit/phpunit": "^11.5|^12.0" }, "type": "library", "extra": { @@ -3543,7 +3699,7 @@ ], "support": { "issues": "https://github.com/lorisleiva/laravel-actions/issues", - "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.9.1" + "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.10.1" }, "funding": [ { @@ -3551,30 +3707,30 @@ "type": "github" } ], - "time": "2025-08-10T08:58:19+00:00" + "time": "2026-03-19T13:33:12+00:00" }, { "name": "lorisleiva/lody", - "version": "v0.6.0", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/lorisleiva/lody.git", - "reference": "6bada710ebc75f06fdf62db26327be1592c4f014" + "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lorisleiva/lody/zipball/6bada710ebc75f06fdf62db26327be1592c4f014", - "reference": "6bada710ebc75f06fdf62db26327be1592c4f014", + "url": "https://api.github.com/repos/lorisleiva/lody/zipball/82ecb6faa55fb20109e6959f42f0f652cd77674b", + "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b", "shasum": "" }, "require": { - "illuminate/contracts": "^10.0|^11.0|^12.0", - "php": "^8.1" + "illuminate/contracts": "^11.0|^12.0|^13.0", + "php": "^8.2" }, "require-dev": { - "orchestra/testbench": "^10.0", - "pestphp/pest": "^2.34|^3.0", - "phpunit/phpunit": "^10.5|^11.5" + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpunit/phpunit": "^11.5|^12.0" }, "type": "library", "extra": { @@ -3615,7 +3771,7 @@ ], "support": { "issues": "https://github.com/lorisleiva/lody/issues", - "source": "https://github.com/lorisleiva/lody/tree/v0.6.0" + "source": "https://github.com/lorisleiva/lody/tree/v0.7.0" }, "funding": [ { @@ -3623,7 +3779,7 @@ "type": "github" } ], - "time": "2025-03-01T19:21:17+00:00" + "time": "2026-03-18T12:49:31+00:00" }, { "name": "monolog/monolog", @@ -3796,16 +3952,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.11.3", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", + "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", "shasum": "" }, "require": { @@ -3897,20 +4053,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-03-11T17:23:39+00:00" }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -3918,8 +4074,10 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3960,22 +4118,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -3987,8 +4145,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -4049,9 +4209,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -4166,31 +4326,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -4222,7 +4382,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -4233,7 +4393,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -4249,7 +4409,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "nyholm/psr7", @@ -4799,16 +4959,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "5.6.7", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", "shasum": "" }, "require": { @@ -4857,9 +5017,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-18T20:47:46+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -4996,16 +5156,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.49", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -5086,7 +5246,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -5102,7 +5262,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:17:28+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -5219,16 +5379,16 @@ }, { "name": "poliander/cron", - "version": "3.3.0", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/poliander/cron.git", - "reference": "13892a8d7f90c7e93947f21e115037b6a0d979bd" + "reference": "8b6fc91b86de3d973f6ea16eda846f522ed1ce7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/poliander/cron/zipball/13892a8d7f90c7e93947f21e115037b6a0d979bd", - "reference": "13892a8d7f90c7e93947f21e115037b6a0d979bd", + "url": "https://api.github.com/repos/poliander/cron/zipball/8b6fc91b86de3d973f6ea16eda846f522ed1ce7a", + "reference": "8b6fc91b86de3d973f6ea16eda846f522ed1ce7a", "shasum": "" }, "require": { @@ -5257,9 +5417,9 @@ "homepage": "https://github.com/poliander/cron", "support": { "issues": "https://github.com/poliander/cron/issues", - "source": "https://github.com/poliander/cron/tree/3.3.0" + "source": "https://github.com/poliander/cron/tree/3.3.1" }, - "time": "2025-11-23T17:30:50+00:00" + "time": "2026-03-05T19:37:26+00:00" }, { "name": "pragmarx/google2fa", @@ -5776,16 +5936,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -5849,9 +6009,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "purplepixie/phpdns", @@ -6288,16 +6448,16 @@ }, { "name": "sentry/sentry", - "version": "4.19.1", + "version": "4.23.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3" + "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3", - "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/121a674d5fffcdb8e414b75c1b76edba8e592b66", + "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66", "shasum": "" }, "require": { @@ -6318,10 +6478,15 @@ "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^1.0", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", - "phpunit/phpunit": "^8.5|^9.6", - "vimeo/psalm": "^4.17" + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6" }, "suggest": { "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." @@ -6360,7 +6525,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.19.1" + "source": "https://github.com/getsentry/sentry-php/tree/4.23.0" }, "funding": [ { @@ -6372,40 +6537,41 @@ "type": "custom" } ], - "time": "2025-12-02T15:57:41+00:00" + "time": "2026-03-23T13:15:52+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.20.1", + "version": "4.24.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72" + "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/503853fa7ee74b34b64e76f1373db86cd11afe72", - "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/f823bd85e38e06cb4f1b7a82d48a2fc95320b31d", + "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d", "shasum": "" }, "require": { - "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", + "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.19.0", + "sentry/sentry": "^4.23.0", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.11", "guzzlehttp/guzzle": "^7.2", "laravel/folio": "^1.1", - "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", + "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", + "laravel/octane": "^2.15", "laravel/pennant": "^1.0", - "livewire/livewire": "^2.0 | ^3.0", + "livewire/livewire": "^2.0 | ^3.0 | ^4.0", "mockery/mockery": "^1.3", - "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0", + "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.4 | ^9.3 | ^10.4 | ^11.5" + "phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5" }, "type": "library", "extra": { @@ -6450,7 +6616,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.20.1" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.24.0" }, "funding": [ { @@ -6462,20 +6628,20 @@ "type": "custom" } ], - "time": "2026-01-07T08:53:19+00:00" + "time": "2026-03-24T10:33:54+00:00" }, { "name": "socialiteproviders/authentik", - "version": "5.2.0", + "version": "5.3.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Authentik.git", - "reference": "4cf129cf04728a38e0531c54454464b162f0fa66" + "reference": "4ef0ca226d3be29dc0523f3afc86b63fd6b755b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4cf129cf04728a38e0531c54454464b162f0fa66", - "reference": "4cf129cf04728a38e0531c54454464b162f0fa66", + "url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4ef0ca226d3be29dc0523f3afc86b63fd6b755b4", + "reference": "4ef0ca226d3be29dc0523f3afc86b63fd6b755b4", "shasum": "" }, "require": { @@ -6512,7 +6678,7 @@ "issues": "https://github.com/socialiteproviders/providers/issues", "source": "https://github.com/socialiteproviders/providers" }, - "time": "2023-11-07T22:21:16+00:00" + "time": "2026-02-04T14:27:03+00:00" }, { "name": "socialiteproviders/clerk", @@ -6708,22 +6874,22 @@ }, { "name": "socialiteproviders/manager", - "version": "v4.8.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", "shasum": "" }, "require": { - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", "laravel/socialite": "^5.5", - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "mockery/mockery": "^1.2", @@ -6778,7 +6944,7 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2025-02-24T19:33:30+00:00" + "time": "2026-03-18T22:13:24+00:00" }, { "name": "socialiteproviders/microsoft-azure", @@ -6883,16 +7049,16 @@ }, { "name": "spatie/backtrace", - "version": "1.8.1", + "version": "1.8.2", "source": { "type": "git", "url": "https://github.com/spatie/backtrace.git", - "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110" + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/backtrace/zipball/8c0f16a59ae35ec8c62d85c3c17585158f430110", - "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc", "shasum": "" }, "require": { @@ -6903,7 +7069,7 @@ "laravel/serializable-closure": "^1.3 || ^2.0", "phpunit/phpunit": "^9.3 || ^11.4.3", "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6", - "symfony/var-dumper": "^5.1 || ^6.0 || ^7.0" + "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6931,7 +7097,7 @@ ], "support": { "issues": "https://github.com/spatie/backtrace/issues", - "source": "https://github.com/spatie/backtrace/tree/1.8.1" + "source": "https://github.com/spatie/backtrace/tree/1.8.2" }, "funding": [ { @@ -6943,7 +7109,7 @@ "type": "other" } ], - "time": "2025-08-26T08:22:30+00:00" + "time": "2026-03-11T13:48:28+00:00" }, { "name": "spatie/commonmark-shiki-highlighter", @@ -7007,29 +7173,29 @@ }, { "name": "spatie/laravel-activitylog", - "version": "4.11.0", + "version": "4.12.3", "source": { "type": "git", "url": "https://github.com/spatie/laravel-activitylog.git", - "reference": "cd7c458f0e128e56eb2d71977d67a846ce4cc10f" + "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/cd7c458f0e128e56eb2d71977d67a846ce4cc10f", - "reference": "cd7c458f0e128e56eb2d71977d67a846ce4cc10f", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0", + "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0", "shasum": "" }, "require": { - "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", - "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0", - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.6.3" }, "require-dev": { "ext-json": "*", - "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0", - "pestphp/pest": "^1.20 || ^2.0 || ^3.0" + "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0 || ^11.0", + "pestphp/pest": "^1.20 || ^2.0 || ^3.0 || ^4.0" }, "type": "library", "extra": { @@ -7082,7 +7248,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-activitylog/issues", - "source": "https://github.com/spatie/laravel-activitylog/tree/4.11.0" + "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.3" }, "funding": [ { @@ -7094,24 +7260,24 @@ "type": "github" } ], - "time": "2026-01-31T12:25:02+00:00" + "time": "2026-03-24T12:33:53+00:00" }, { "name": "spatie/laravel-data", - "version": "4.19.1", + "version": "4.20.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "41ed0472250676f19440fb24d7b62a8d43abdb89" + "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/41ed0472250676f19440fb24d7b62a8d43abdb89", - "reference": "41ed0472250676f19440fb24d7b62a8d43abdb89", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/5490cb15de6fc8b35a8cd2f661fac072d987a1ad", + "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad", "shasum": "" }, "require": { - "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "phpdocumentor/reflection": "^6.0", "spatie/laravel-package-tools": "^1.9.0", @@ -7121,10 +7287,10 @@ "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", "inertiajs/inertia-laravel": "^2.0", - "livewire/livewire": "^3.0", + "livewire/livewire": "^3.0|^4.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63|^3.0", - "orchestra/testbench": "^8.37.0|^9.16|^10.9", + "orchestra/testbench": "^8.37.0|^9.16|^10.9|^11.0", "pestphp/pest": "^2.36|^3.8|^4.3", "pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0", "pestphp/pest-plugin-livewire": "^2.1|^3.0|^4.0", @@ -7168,7 +7334,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.19.1" + "source": "https://github.com/spatie/laravel-data/tree/4.20.1" }, "funding": [ { @@ -7176,27 +7342,27 @@ "type": "github" } ], - "time": "2026-01-28T13:10:20+00:00" + "time": "2026-03-18T07:44:01+00:00" }, { "name": "spatie/laravel-markdown", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-markdown.git", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280" + "reference": "eabe8c7e31c2739ad0fe63ba04eb2e3189608187" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280", + "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/eabe8c7e31c2739ad0fe63ba04eb2e3189608187", + "reference": "eabe8c7e31c2739ad0fe63ba04eb2e3189608187", "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", + "illuminate/cache": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^9.0|^10.0|^11.0|^12.0|^13.0", "league/commonmark": "^2.6.0", "php": "^8.1", "spatie/commonmark-shiki-highlighter": "^2.5", @@ -7205,9 +7371,9 @@ "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", + "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0|^11.0", + "pestphp/pest": "^1.22|^2.0|^3.7|^4.4", + "phpunit/phpunit": "^9.3|^11.5.3|^12.5.12", "spatie/laravel-ray": "^1.23", "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0", "vimeo/psalm": "^4.8|^6.7" @@ -7244,7 +7410,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1" + "source": "https://github.com/spatie/laravel-markdown/tree/2.8.0" }, "funding": [ { @@ -7252,33 +7418,33 @@ "type": "github" } ], - "time": "2025-02-21T13:43:18+00:00" + "time": "2026-02-22T18:53:36+00:00" }, { "name": "spatie/laravel-package-tools", - "version": "1.92.7", + "version": "1.93.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", - "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", "shasum": "" }, "require": { - "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", - "php": "^8.0" + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" }, "require-dev": { "mockery/mockery": "^1.5", - "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", - "pestphp/pest": "^1.23|^2.1|^3.1", - "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", - "phpunit/phpunit": "^9.5.24|^10.5|^11.5", - "spatie/pest-plugin-test-time": "^1.1|^2.2" + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" }, "type": "library", "autoload": { @@ -7305,7 +7471,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" }, "funding": [ { @@ -7313,40 +7479,40 @@ "type": "github" } ], - "time": "2025-07-17T15:46:43+00:00" + "time": "2026-02-21T12:49:54+00:00" }, { "name": "spatie/laravel-ray", - "version": "1.43.5", + "version": "1.43.7", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "2003e627d4a17e8411fff18153e47a754f0c028d" + "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/2003e627d4a17e8411fff18153e47a754f0c028d", - "reference": "2003e627d4a17e8411fff18153e47a754f0c028d", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/d550d0b5bf87bb1b1668089f3c843e786ee522d3", + "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-json": "*", - "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^7.4|^8.0", "spatie/backtrace": "^1.7.1", "spatie/ray": "^1.45.0", "symfony/stopwatch": "4.2|^5.1|^6.0|^7.0|^8.0", - "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0" + "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0|^4.0" }, "require-dev": { "guzzlehttp/guzzle": "^7.3", - "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", "laravel/pint": "^1.27", - "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "pestphp/pest": "^1.22|^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10.57|^2.0.2", "phpunit/phpunit": "^9.3|^10.1|^11.0.10|^12.4", @@ -7390,7 +7556,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.43.5" + "source": "https://github.com/spatie/laravel-ray/tree/1.43.7" }, "funding": [ { @@ -7402,26 +7568,26 @@ "type": "other" } ], - "time": "2026-01-26T19:05:19+00:00" + "time": "2026-03-06T08:19:04+00:00" }, { "name": "spatie/laravel-schemaless-attributes", - "version": "2.5.1", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-schemaless-attributes.git", - "reference": "3561875fb6886ae55e5378f20ba5ac87f20b265a" + "reference": "7d17ab5f434ae47324b849e007ce80669966c14e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-schemaless-attributes/zipball/3561875fb6886ae55e5378f20ba5ac87f20b265a", - "reference": "3561875fb6886ae55e5378f20ba5ac87f20b265a", + "url": "https://api.github.com/repos/spatie/laravel-schemaless-attributes/zipball/7d17ab5f434ae47324b849e007ce80669966c14e", + "reference": "7d17ab5f434ae47324b849e007ce80669966c14e", "shasum": "" }, "require": { - "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", "spatie/laravel-package-tools": "^1.4.3" }, @@ -7429,9 +7595,9 @@ "brianium/paratest": "^6.2|^7.4", "mockery/mockery": "^1.4", "nunomaduro/collision": "^5.3|^6.0|^8.0", - "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0", - "pestphp/pest-plugin-laravel": "^1.3|^2.1|^3.1", - "phpunit/phpunit": "^9.6|^10.5|^11.5|^12.0" + "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0|^11.0", + "pestphp/pest-plugin-laravel": "^1.3|^2.1|^3.1|^4.0", + "phpunit/phpunit": "^9.6|^10.5|^11.5|^12.3" }, "type": "library", "extra": { @@ -7466,7 +7632,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-schemaless-attributes/issues", - "source": "https://github.com/spatie/laravel-schemaless-attributes/tree/2.5.1" + "source": "https://github.com/spatie/laravel-schemaless-attributes/tree/2.6.0" }, "funding": [ { @@ -7478,7 +7644,7 @@ "type": "github" } ], - "time": "2025-02-10T09:28:22+00:00" + "time": "2026-02-21T15:13:56+00:00" }, { "name": "spatie/macroable", @@ -7533,29 +7699,29 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.3.3", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "552a5b974a9853a32e5677a66e85ae615a96a90b" + "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/552a5b974a9853a32e5677a66e85ae615a96a90b", - "reference": "552a5b974a9853a32e5677a66e85ae615a96a90b", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/9a53c79b48fca8b6d15faa8cbba47cc430355146", + "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146", "shasum": "" }, "require": { - "illuminate/collections": "^11.0|^12.0", + "illuminate/collections": "^11.0|^12.0|^13.0", "php": "^8.3", "spatie/laravel-package-tools": "^1.92.7", "symfony/finder": "^6.0|^7.3.5|^8.0" }, "require-dev": { "amphp/parallel": "^2.3.2", - "illuminate/console": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", "nunomaduro/collision": "^7.0|^8.8.3", - "orchestra/testbench": "^9.5|^10.8", + "orchestra/testbench": "^9.5|^10.8|^11.0", "pestphp/pest": "^3.8|^4.0", "pestphp/pest-plugin-laravel": "^3.2|^4.0", "phpstan/extension-installer": "^1.4.3", @@ -7600,7 +7766,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.3" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.0" }, "funding": [ { @@ -7608,20 +7774,20 @@ "type": "github" } ], - "time": "2025-11-24T16:41:01+00:00" + "time": "2026-02-21T15:57:15+00:00" }, { "name": "spatie/ray", - "version": "1.45.0", + "version": "1.47.0", "source": { "type": "git", "url": "https://github.com/spatie/ray.git", - "reference": "68920c418d10fe103722d366faa575533d26434f" + "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/68920c418d10fe103722d366faa575533d26434f", - "reference": "68920c418d10fe103722d366faa575533d26434f", + "url": "https://api.github.com/repos/spatie/ray/zipball/3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", + "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", "shasum": "" }, "require": { @@ -7635,7 +7801,7 @@ "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0" }, "require-dev": { - "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.63|^3.8.4", "pestphp/pest": "^1.22", "phpstan/phpstan": "^1.10.57|^2.0.3", @@ -7681,7 +7847,7 @@ ], "support": { "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.45.0" + "source": "https://github.com/spatie/ray/tree/1.47.0" }, "funding": [ { @@ -7693,7 +7859,7 @@ "type": "other" } ], - "time": "2026-01-26T18:45:30+00:00" + "time": "2026-02-20T20:42:26+00:00" }, { "name": "spatie/shiki-php", @@ -7824,27 +7990,27 @@ }, { "name": "stevebauman/purify", - "version": "v6.3.1", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/stevebauman/purify.git", - "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", - "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/deba4aa55a45a7593c369b52d481c87b545a5bf8", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8", "shasum": "" }, "require": { "ezyang/htmlpurifier": "^4.17", - "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": ">=7.4" }, "require-dev": { - "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", - "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3" + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3|^12.5.12" }, "type": "library", "extra": { @@ -7884,9 +8050,9 @@ ], "support": { "issues": "https://github.com/stevebauman/purify/issues", - "source": "https://github.com/stevebauman/purify/tree/v6.3.1" + "source": "https://github.com/stevebauman/purify/tree/v6.3.2" }, - "time": "2025-05-21T16:53:09+00:00" + "time": "2026-03-18T16:42:42+00:00" }, { "name": "stripe/stripe-php", @@ -8026,16 +8192,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -8100,7 +8266,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -8120,20 +8286,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "2a178bf80f05dbbe469a337730eba79d61315262" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", + "reference": "2a178bf80f05dbbe469a337730eba79d61315262", "shasum": "" }, "require": { @@ -8169,7 +8335,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.6" }, "funding": [ { @@ -8189,7 +8355,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8503,16 +8669,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -8549,7 +8715,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -8569,20 +8735,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -8617,7 +8783,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -8637,20 +8803,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "shasum": "" }, "require": { @@ -8699,7 +8865,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.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" }, "funding": [ { @@ -8719,20 +8885,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-03-06T13:15:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", "shasum": "" }, "require": { @@ -8774,7 +8940,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -8818,7 +8984,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.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" }, "funding": [ { @@ -8838,20 +9004,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-03-06T16:33:18+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -8902,7 +9068,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -8922,20 +9088,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "shasum": "" }, "require": { @@ -8946,7 +9112,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -8954,7 +9120,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -8991,7 +9157,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.7" }, "funding": [ { @@ -9011,7 +9177,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-03-05T15:24:09+00:00" }, { "name": "symfony/options-resolver", @@ -10151,16 +10317,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -10212,7 +10378,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -10232,7 +10398,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", @@ -10389,16 +10555,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -10455,7 +10621,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -10475,20 +10641,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/translation", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", "shasum": "" }, "require": { @@ -10548,7 +10714,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.0.6" }, "funding": [ { @@ -10568,7 +10734,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/translation-contracts", @@ -10732,16 +10898,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -10795,7 +10961,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -10815,20 +10981,20 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", "shasum": "" }, "require": { @@ -10871,7 +11037,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.6" }, "funding": [ { @@ -10891,7 +11057,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -10950,33 +11116,45 @@ }, { "name": "visus/cuid2", - "version": "4.1.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/visus-io/php-cuid2.git", - "reference": "17c9b3098d556bb2556a084c948211333cc19c79" + "reference": "834c8a1c04684931600ee7a4189150b331a5b56c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/17c9b3098d556bb2556a084c948211333cc19c79", - "reference": "17c9b3098d556bb2556a084c948211333cc19c79", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/834c8a1c04684931600ee7a4189150b331a5b56c", + "reference": "834c8a1c04684931600ee7a4189150b331a5b56c", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2", + "symfony/polyfill-php83": "^1.32" }, "require-dev": { + "captainhook/captainhook": "^5.27", + "captainhook/hook-installer": "^1.0", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", "ergebnis/composer-normalize": "^2.29", - "ext-ctype": "*", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpbench/phpbench": "^1.4", "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "^5.4" + "phpunit/phpunit": "^10.5", + "ramsey/conventional-commits": "^1.5", + "slevomat/coding-standard": "^8.25", + "squizlabs/php_codesniffer": "^4.0" }, "suggest": { - "ext-gmp": "*" + "ext-gmp": "Enables faster math with arbitrary precision integers using GMP." }, "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, "autoload": { "files": [ "src/compat.php" @@ -11002,9 +11180,9 @@ ], "support": { "issues": "https://github.com/visus-io/php-cuid2/issues", - "source": "https://github.com/visus-io/php-cuid2/tree/4.1.0" + "source": "https://github.com/visus-io/php-cuid2/tree/6.0.0" }, - "time": "2024-05-14T13:23:35+00:00" + "time": "2025-12-18T14:52:27+00:00" }, { "name": "vlucas/phpdotenv", @@ -11334,31 +11512,31 @@ }, { "name": "zbateson/mail-mime-parser", - "version": "3.0.5", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/zbateson/mail-mime-parser.git", - "reference": "ff054c8e05310c445c2028c6128a4319cc9f6aa8" + "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/ff054c8e05310c445c2028c6128a4319cc9f6aa8", - "reference": "ff054c8e05310c445c2028c6128a4319cc9f6aa8", + "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/3db681988a48fdffdba551dcc6b2f4c2da574540", + "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540", "shasum": "" }, "require": { "guzzlehttp/psr7": "^2.5", - "php": ">=8.0", + "php": ">=8.1", "php-di/php-di": "^6.0|^7.0", "psr/log": "^1|^2|^3", - "zbateson/mb-wrapper": "^2.0", - "zbateson/stream-decorators": "^2.1" + "zbateson/mb-wrapper": "^2.0 || ^3.0", + "zbateson/stream-decorators": "^2.1 || ^3.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "*", + "friendsofphp/php-cs-fixer": "^3.0", "monolog/monolog": "^2|^3", - "phpstan/phpstan": "*", - "phpunit/phpunit": "^9.6" + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5" }, "suggest": { "ext-iconv": "For best support/performance", @@ -11406,31 +11584,31 @@ "type": "github" } ], - "time": "2025-12-02T00:29:16+00:00" + "time": "2026-03-11T18:03:41+00:00" }, { "name": "zbateson/mb-wrapper", - "version": "2.0.1", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/zbateson/mb-wrapper.git", - "reference": "50a14c0c9537f978a61cde9fdc192a0267cc9cff" + "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/50a14c0c9537f978a61cde9fdc192a0267cc9cff", - "reference": "50a14c0c9537f978a61cde9fdc192a0267cc9cff", + "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/f0ee6af2712e92e52ee2552588cd69d21ab3363f", + "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f", "shasum": "" }, "require": { - "php": ">=8.0", + "php": ">=8.1", "symfony/polyfill-iconv": "^1.9", "symfony/polyfill-mbstring": "^1.9" }, "require-dev": { "friendsofphp/php-cs-fixer": "*", "phpstan/phpstan": "*", - "phpunit/phpunit": "^9.6|^10.0" + "phpunit/phpunit": "^10.0|^11.0" }, "suggest": { "ext-iconv": "For best support/performance", @@ -11467,7 +11645,7 @@ ], "support": { "issues": "https://github.com/zbateson/mb-wrapper/issues", - "source": "https://github.com/zbateson/mb-wrapper/tree/2.0.1" + "source": "https://github.com/zbateson/mb-wrapper/tree/3.0.0" }, "funding": [ { @@ -11475,31 +11653,31 @@ "type": "github" } ], - "time": "2024-12-20T22:05:33+00:00" + "time": "2026-02-13T19:33:26+00:00" }, { "name": "zbateson/stream-decorators", - "version": "2.1.1", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/zbateson/stream-decorators.git", - "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5" + "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/32a2a62fb0f26313395c996ebd658d33c3f9c4e5", - "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5", + "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/0c0e79a8c960055c0e2710357098eedc07e6697a", + "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a", "shasum": "" }, "require": { "guzzlehttp/psr7": "^2.5", - "php": ">=8.0", - "zbateson/mb-wrapper": "^2.0" + "php": ">=8.1", + "zbateson/mb-wrapper": "^2.0 || ^3.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "*", "phpstan/phpstan": "*", - "phpunit/phpunit": "^9.6|^10.0" + "phpunit/phpunit": "^10.0 || ^11.0" }, "type": "library", "autoload": { @@ -11530,7 +11708,7 @@ ], "support": { "issues": "https://github.com/zbateson/stream-decorators/issues", - "source": "https://github.com/zbateson/stream-decorators/tree/2.1.1" + "source": "https://github.com/zbateson/stream-decorators/tree/3.0.0" }, "funding": [ { @@ -11538,20 +11716,20 @@ "type": "github" } ], - "time": "2024-04-29T21:42:39+00:00" + "time": "2026-02-13T19:45:34+00:00" }, { "name": "zircote/swagger-php", - "version": "5.8.0", + "version": "5.8.3", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "9cf5d1a0c159894026708c9e837e69140c2d3922" + "reference": "098223019f764a16715f64089a58606096719c98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/9cf5d1a0c159894026708c9e837e69140c2d3922", - "reference": "9cf5d1a0c159894026708c9e837e69140c2d3922", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/098223019f764a16715f64089a58606096719c98", + "reference": "098223019f764a16715f64089a58606096719c98", "shasum": "" }, "require": { @@ -11624,7 +11802,7 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.8.0" + "source": "https://github.com/zircote/swagger-php/tree/5.8.3" }, "funding": [ { @@ -11632,10 +11810,1232 @@ "type": "github" } ], - "time": "2026-01-28T01:27:48+00:00" + "time": "2026-03-02T00:47:18+00:00" } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-02-08T18:16:29+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "barryvdh/laravel-debugbar", "version": "v3.16.5", @@ -11723,16 +13123,16 @@ }, { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.19.2", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", "shasum": "" }, "require": { @@ -11743,24 +13143,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -11800,7 +13200,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" }, "funding": [ { @@ -11812,26 +13212,70 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-03-09T14:33:17+00:00" }, { - "name": "driftingly/rector-laravel", - "version": "2.1.9", + "name": "daverandom/libdns", + "version": "v2.1.0", "source": { "type": "git", - "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "aee9d4a1d489e7ec484fc79f33137f8ee051b3f7" + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/aee9d4a1d489e7ec484fc79f33137f8ee051b3f7", - "reference": "aee9d4a1d489e7ec484fc79f33137f8ee051b3f7", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "driftingly/rector-laravel", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/driftingly/rector-laravel.git", + "reference": "807840ceb09de6764cbfcce0719108d044a459a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/807840ceb09de6764cbfcce0719108d044a459a9", + "reference": "807840ceb09de6764cbfcce0719108d044a459a9", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", "rector/rector": "^2.2.7", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11 || ^2.0" }, "type": "rector-extension", "autoload": { @@ -11846,9 +13290,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.1.9" + "source": "https://github.com/driftingly/rector-laravel/tree/2.2.0" }, - "time": "2025-12-25T23:31:36+00:00" + "time": "2026-03-19T17:24:38+00:00" }, { "name": "fakerphp/faker", @@ -12097,34 +13541,92 @@ "time": "2025-04-30T06:54:44+00:00" }, { - "name": "laravel/boost", - "version": "v2.1.1", + "name": "kelunik/certificate", + "version": "v1.1.3", "source": { "type": "git", - "url": "https://github.com/laravel/boost.git", - "reference": "1c7d6f44c96937a961056778b9143218b1183302" + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/1c7d6f44c96937a961056778b9143218b1183302", - "reference": "1c7d6f44c96937a961056778b9143218b1183302", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, + { + "name": "laravel/boost", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506", + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^11.45.3|^12.41.1", - "illuminate/contracts": "^11.45.3|^12.41.1", - "illuminate/routing": "^11.45.3|^12.41.1", - "illuminate/support": "^11.45.3|^12.41.1", - "laravel/mcp": "^0.5.1", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0", "laravel/prompts": "^0.3.10", - "laravel/roster": "^0.2.9", + "laravel/roster": "^0.5.0", "php": "^8.2" }, "require-dev": { "laravel/pint": "^1.27.0", "mockery/mockery": "^1.6.12", - "orchestra/testbench": "^9.15.0|^10.6", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" @@ -12160,43 +13662,43 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-02-06T10:41:29+00:00" + "time": "2026-03-25T16:37:40+00:00" }, { "name": "laravel/dusk", - "version": "v8.3.4", + "version": "v8.5.0", "source": { "type": "git", "url": "https://github.com/laravel/dusk.git", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6" + "reference": "f9f75666bed46d1ebca13792447be6e753f4e790" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "url": "https://api.github.com/repos/laravel/dusk/zipball/f9f75666bed46d1ebca13792447be6e753f4e790", + "reference": "f9f75666bed46d1ebca13792447be6e753f4e790", "shasum": "" }, "require": { "ext-json": "*", "ext-zip": "*", "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0", - "symfony/finder": "^6.2|^7.0", - "symfony/process": "^6.2|^7.0", + "symfony/console": "^6.2|^7.0|^8.0", + "symfony/finder": "^6.2|^7.0|^8.0", + "symfony/process": "^6.2|^7.0|^8.0", "vlucas/phpdotenv": "^5.2" }, "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8", + "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.1|^11.0|^12.0.1", "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0" + "symfony/yaml": "^6.2|^7.0|^8.0" }, "suggest": { "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." @@ -12232,22 +13734,22 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.3.4" + "source": "https://github.com/laravel/dusk/tree/v8.5.0" }, - "time": "2025-11-20T16:26:16+00:00" + "time": "2026-03-21T11:50:49+00:00" }, { "name": "laravel/mcp", - "version": "v0.5.5", + "version": "v0.6.4", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "b3327bb75fd2327577281e507e2dbc51649513d6" + "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/b3327bb75fd2327577281e507e2dbc51649513d6", - "reference": "b3327bb75fd2327577281e507e2dbc51649513d6", + "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", + "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42", "shasum": "" }, "require": { @@ -12307,20 +13809,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-02-05T14:05:18+00:00" + "time": "2026-03-19T12:37:13+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -12331,13 +13833,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -12374,35 +13877,35 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "laravel/roster", - "version": "v0.2.9", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "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" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "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", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -12435,34 +13938,35 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-10-20T09:56:46+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/telescope", - "version": "v5.16.1", + "version": "v5.19.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "dc114b94f025b8c16b5eb3194b4ddc0e46d5310c" + "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/dc114b94f025b8c16b5eb3194b4ddc0e46d5310c", - "reference": "dc114b94f025b8c16b5eb3194b4ddc0e46d5310c", + "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b", + "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b", "shasum": "" }, "require": { "ext-json": "*", - "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", "php": "^8.0", - "symfony/console": "^5.3|^6.0|^7.0", - "symfony/var-dumper": "^5.0|^6.0|^7.0" + "symfony/console": "^5.3|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "ext-gd": "*", "guzzlehttp/guzzle": "^6.0|^7.0", "laravel/octane": "^1.4|^2.0", - "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -12501,9 +14005,93 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.16.1" + "source": "https://github.com/laravel/telescope/tree/v5.19.0" }, - "time": "2025-12-30T17:31:31+00:00" + "time": "2026-03-24T18:37:14+00:00" + }, + { + "name": "league/uri-components", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "shasum": "" + }, + "require": { + "league/uri": "^7.8.1", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" }, { "name": "mockery/mockery", @@ -12650,39 +14238,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -12745,45 +14330,45 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "pestphp/pest", - "version": "v4.3.2", + "version": "v4.4.3", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "url": "https://api.github.com/repos/pestphp/pest/zipball/e6ab897594312728ef2e32d586cb4f6780b1b495", + "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.1", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.19.2", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.8", - "symfony/process": "^7.4.4|^8.0.0" + "phpunit/phpunit": "^12.5.14", + "symfony/process": "^7.4.5|^8.0.5" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.8", + "phpunit/phpunit": ">12.5.14", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.2.1", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.0", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "psy/psysh": "^0.12.21" }, "bin": [ "bin/pest" @@ -12849,7 +14434,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.2" + "source": "https://github.com/pestphp/pest/tree/v4.4.3" }, "funding": [ { @@ -12861,7 +14446,7 @@ "type": "github" } ], - "time": "2026-01-28T01:01:19+00:00" + "time": "2026-03-21T13:14:39+00:00" }, { "name": "pestphp/pest-plugin", @@ -13003,6 +14588,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "48bc408033281974952a6b296592cef3b920a2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", + "reference": "48bc408033281974952a6b296592cef3b920a2db", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.4", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.10", + "nunomaduro/collision": "^8.9.0", + "orchestra/testbench": "^10.9.0", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-02-17T14:54:40+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v4.0.1", @@ -13395,11 +15063,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.44", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", "shasum": "" }, "require": { @@ -13444,20 +15112,20 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-03-25T17:34:21+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -13513,7 +15181,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -13533,7 +15201,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", @@ -13794,16 +15462,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", "shasum": "" }, "require": { @@ -13817,8 +15485,8 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -13829,6 +15497,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -13871,7 +15540,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" }, "funding": [ { @@ -13895,25 +15564,25 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-18T12:38:40+00:00" }, { "name": "rector/rector", - "version": "2.3.5", + "version": "2.3.9", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" + "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4", + "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.36" + "phpstan/phpstan": "^2.1.40" }, "conflict": { "rector/rector-doctrine": "*", @@ -13947,7 +15616,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.5" + "source": "https://github.com/rectorphp/rector/tree/2.3.9" }, "funding": [ { @@ -13955,7 +15624,79 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-03-16T09:43:55+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + }, + "time": "2025-08-27T21:33:23+00:00" }, { "name": "sebastian/cli-parser", @@ -14245,16 +15986,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", "shasum": "" }, "require": { @@ -14297,7 +16038,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" }, "funding": [ { @@ -14317,7 +16058,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-03-15T07:05:40+00:00" }, { "name": "sebastian/exporter", @@ -14975,26 +16716,26 @@ }, { "name": "spatie/flare-client-php", - "version": "1.10.1", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/spatie/flare-client-php.git", - "reference": "bf1716eb98bd689451b071548ae9e70738dce62f" + "reference": "fb3ffb946675dba811fbde9122224db2f84daca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/bf1716eb98bd689451b071548ae9e70738dce62f", - "reference": "bf1716eb98bd689451b071548ae9e70738dce62f", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/fb3ffb946675dba811fbde9122224db2f84daca9", + "reference": "fb3ffb946675dba811fbde9122224db2f84daca9", "shasum": "" }, "require": { - "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", "spatie/backtrace": "^1.6.1", - "symfony/http-foundation": "^5.2|^6.0|^7.0", - "symfony/mime": "^5.2|^6.0|^7.0", - "symfony/process": "^5.2|^6.0|^7.0", - "symfony/var-dumper": "^5.2|^6.0|^7.0" + "symfony/http-foundation": "^5.2|^6.0|^7.0|^8.0", + "symfony/mime": "^5.2|^6.0|^7.0|^8.0", + "symfony/process": "^5.2|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.2|^6.0|^7.0|^8.0" }, "require-dev": { "dms/phpunit-arraysubset-asserts": "^0.5.0", @@ -15032,7 +16773,7 @@ ], "support": { "issues": "https://github.com/spatie/flare-client-php/issues", - "source": "https://github.com/spatie/flare-client-php/tree/1.10.1" + "source": "https://github.com/spatie/flare-client-php/tree/1.11.0" }, "funding": [ { @@ -15040,41 +16781,44 @@ "type": "github" } ], - "time": "2025-02-14T13:42:06+00:00" + "time": "2026-03-17T08:06:16+00:00" }, { "name": "spatie/ignition", - "version": "1.15.1", + "version": "1.16.0", "source": { "type": "git", "url": "https://github.com/spatie/ignition.git", - "reference": "31f314153020aee5af3537e507fef892ffbf8c85" + "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ignition/zipball/31f314153020aee5af3537e507fef892ffbf8c85", - "reference": "31f314153020aee5af3537e507fef892ffbf8c85", + "url": "https://api.github.com/repos/spatie/ignition/zipball/b59385bb7aa24dae81bcc15850ebecfda7b40838", + "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", "php": "^8.0", - "spatie/error-solutions": "^1.0", - "spatie/flare-client-php": "^1.7", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "spatie/backtrace": "^1.7.1", + "spatie/error-solutions": "^1.1.2", + "spatie/flare-client-php": "^1.9", + "symfony/console": "^5.4.42|^6.0|^7.0|^8.0", + "symfony/http-foundation": "^5.4.42|^6.0|^7.0|^8.0", + "symfony/mime": "^5.4.42|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.4.42|^6.0|^7.0|^8.0" }, "require-dev": { - "illuminate/cache": "^9.52|^10.0|^11.0|^12.0", + "illuminate/cache": "^9.52|^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.4", - "pestphp/pest": "^1.20|^2.0", + "pestphp/pest": "^1.20|^2.0|^3.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-phpunit": "^1.0", "psr/simple-cache-implementation": "*", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", + "symfony/cache": "^5.4.38|^6.0|^7.0|^8.0", + "symfony/process": "^5.4.35|^6.0|^7.0|^8.0", "vlucas/phpdotenv": "^5.5" }, "suggest": { @@ -15123,38 +16867,38 @@ "type": "github" } ], - "time": "2025-02-21T14:31:39+00:00" + "time": "2026-03-17T10:51:08+00:00" }, { "name": "spatie/laravel-ignition", - "version": "2.10.0", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ignition.git", - "reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5" + "reference": "45b3b6e1e73fc161cba2149972698644b99594ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/2abefdcca6074a9155f90b4ccb3345af8889d5f5", - "reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee", + "reference": "45b3b6e1e73fc161cba2149972698644b99594ee", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "illuminate/support": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0|^13.0", "nesbot/carbon": "^2.72|^3.0", "php": "^8.2", - "spatie/ignition": "^1.15.1", + "spatie/ignition": "^1.16", "symfony/console": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, "require-dev": { - "livewire/livewire": "^3.7.0|^4.0", + "livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support", "mockery/mockery": "^1.6.12", - "openai-php/client": "^0.10.3", - "orchestra/testbench": "^v9.16.0|^10.6", + "openai-php/client": "^0.10.3|^0.19", + "orchestra/testbench": "^v9.16.0|^10.6|^11.0", "pestphp/pest": "^3.7|^4.0", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan-deprecation-rules": "^2.0.3", @@ -15215,7 +16959,7 @@ "type": "github" } ], - "time": "2026-01-20T13:16:11+00:00" + "time": "2026-03-17T12:20:04+00:00" }, { "name": "staabm/side-effects-detector", @@ -15271,16 +17015,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.5", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" + "reference": "1010624285470eb60e88ed10035102c75b4ea6af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", + "reference": "1010624285470eb60e88ed10035102c75b4ea6af", "shasum": "" }, "require": { @@ -15348,7 +17092,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.5" + "source": "https://github.com/symfony/http-client/tree/v7.4.7" }, "funding": [ { @@ -15368,7 +17112,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-03-05T11:16:58+00:00" }, { "name": "symfony/http-client-contracts", @@ -15450,23 +17194,23 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -15503,9 +17247,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -15567,5 +17311,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/constants.php b/config/constants.php index 0b404fe9d..b8cd8f6d9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.463', - 'helper_version' => '1.0.12', - 'realtime_version' => '1.0.10', + 'version' => '4.0.0-beta.473', + 'helper_version' => '1.0.13', + 'realtime_version' => '1.0.13', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), @@ -55,6 +55,10 @@ 'is_scheduler_enabled' => env('SCHEDULER_ENABLED', true), ], + 'nightwatch' => [ + 'is_nightwatch_enabled' => env('NIGHTWATCH_ENABLED', false), + ], + 'docker' => [ 'minimum_required_version' => '24.0', ], diff --git a/config/database.php b/config/database.php index 366ff90b5..a5e0ba703 100644 --- a/config/database.php +++ b/config/database.php @@ -49,23 +49,15 @@ 'search_path' => 'public', 'sslmode' => 'prefer', 'options' => [ - PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_DISABLE_PREPARES', false), + (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), ], ], 'testing' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_TEST_URL'), - 'host' => env('DB_TEST_HOST', 'postgres'), - 'port' => env('DB_TEST_PORT', '5432'), - 'database' => env('DB_TEST_DATABASE', 'coolify_test'), - 'username' => env('DB_TEST_USERNAME', 'coolify'), - 'password' => env('DB_TEST_PASSWORD', 'password'), - 'charset' => 'utf8', + 'driver' => 'sqlite', + 'database' => ':memory:', 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', + 'foreign_key_constraints' => true, ], ], diff --git a/config/horizon.php b/config/horizon.php index cdabcb1e8..0423f1549 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -184,13 +184,13 @@ 'connection' => 'redis', 'balance' => env('HORIZON_BALANCE', 'false'), 'queue' => env('HORIZON_QUEUES', 'high,default'), - 'maxTime' => 3600, + 'maxTime' => env('HORIZON_MAX_TIME', 0), 'maxJobs' => 400, 'memory' => 128, 'tries' => 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => 3600, + 'timeout' => env('HORIZON_TIMEOUT', 36000), ], ], diff --git a/config/logging.php b/config/logging.php index 1a75978f3..1dbb1135f 100644 --- a/config/logging.php +++ b/config/logging.php @@ -123,7 +123,7 @@ 'driver' => 'daily', 'path' => storage_path('logs/scheduled.log'), 'level' => 'debug', - 'days' => 1, + 'days' => 7, ], 'scheduled-errors' => [ diff --git a/config/purify.php b/config/purify.php index 66dbbb568..a5dcabb92 100644 --- a/config/purify.php +++ b/config/purify.php @@ -49,6 +49,17 @@ 'AutoFormat.RemoveEmpty' => false, ], + 'validation_logs' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]', + 'HTML.ForbiddenElements' => '', + 'CSS.AllowedProperties' => '', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + 'Attr.AllowedFrameTargets' => ['_blank'], + ], + ], /* diff --git a/database/factories/EnvironmentFactory.php b/database/factories/EnvironmentFactory.php new file mode 100644 index 000000000..98959197d --- /dev/null +++ b/database/factories/EnvironmentFactory.php @@ -0,0 +1,16 @@ + fake()->unique()->word(), + 'project_id' => 1, + ]; + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 000000000..0b2b72b8a --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,16 @@ + fake()->unique()->company(), + 'team_id' => 1, + ]; + } +} diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php new file mode 100644 index 000000000..6e4d6d740 --- /dev/null +++ b/database/factories/ScheduledTaskFactory.php @@ -0,0 +1,21 @@ + fake()->word(), + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => Team::factory(), + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 000000000..62c5f7cda --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,19 @@ + fake()->unique()->word(), + 'destination_type' => \App\Models\StandaloneDocker::class, + 'destination_id' => 1, + 'environment_id' => 1, + 'docker_compose_raw' => 'version: "3"', + ]; + } +} diff --git a/database/factories/StandaloneDockerFactory.php b/database/factories/StandaloneDockerFactory.php new file mode 100644 index 000000000..d37785189 --- /dev/null +++ b/database/factories/StandaloneDockerFactory.php @@ -0,0 +1,18 @@ + fake()->uuid(), + 'name' => fake()->unique()->word(), + 'network' => 'coolify', + 'server_id' => 1, + ]; + } +} diff --git a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php index 0c281ff40..5ebf62fe3 100644 --- a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php +++ b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php @@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration { public function up() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb'); DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)'); @@ -18,6 +22,10 @@ public function up() public function down() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid'); DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json'); diff --git a/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php new file mode 100644 index 000000000..abbae3573 --- /dev/null +++ b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php @@ -0,0 +1,36 @@ +string('comment', 256)->nullable(); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->string('comment', 256)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + } +}; diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php index 680c4b4f7..ce38d7cc2 100644 --- a/database/migrations/2025_12_08_135600_add_performance_indexes.php +++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php @@ -22,6 +22,10 @@ public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [$table, $columns, $indexName]) { if (! $this->indexExists($indexName)) { $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns)); @@ -32,6 +36,10 @@ public function up(): void public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [, , $indexName]) { DB::statement("DROP INDEX IF EXISTS \"{$indexName}\""); } diff --git a/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php b/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php new file mode 100644 index 000000000..66d585069 --- /dev/null +++ b/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php @@ -0,0 +1,47 @@ +foreignId('server_id')->nullable()->constrained()->onDelete('cascade'); + // NULL != NULL in PostgreSQL unique indexes, so this only enforces uniqueness + // for server-scoped rows (where server_id is non-null). Other scopes are covered + // by existing unique constraints on ['key', 'project_id', 'team_id'] and ['key', 'environment_id', 'team_id']. + $table->unique(['key', 'server_id', 'team_id']); + }); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::transaction(function () { + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->dropUnique(['key', 'server_id', 'team_id']); + $table->dropForeign(['server_id']); + $table->dropColumn('server_id'); + }); + if (DB::getDriverName() !== 'sqlite') { + DB::statement('ALTER TABLE shared_environment_variables DROP CONSTRAINT IF EXISTS shared_environment_variables_type_check'); + DB::statement("ALTER TABLE shared_environment_variables ADD CONSTRAINT shared_environment_variables_type_check CHECK (type IN ('team', 'project', 'environment'))"); + } + }); + } +}; diff --git a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php new file mode 100644 index 000000000..c67987e67 --- /dev/null +++ b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php @@ -0,0 +1,56 @@ +chunk(100, function ($servers) { + foreach ($servers as $server) { + $existingKeys = SharedEnvironmentVariable::where('type', 'server') + ->where('server_id', $server->id) + ->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME']) + ->pluck('key') + ->toArray(); + + if (! in_array('COOLIFY_SERVER_UUID', $existingKeys)) { + SharedEnvironmentVariable::create([ + 'key' => 'COOLIFY_SERVER_UUID', + 'value' => $server->uuid, + 'type' => 'server', + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'is_literal' => true, + ]); + } + + if (! in_array('COOLIFY_SERVER_NAME', $existingKeys)) { + SharedEnvironmentVariable::create([ + 'key' => 'COOLIFY_SERVER_NAME', + 'value' => $server->name, + 'type' => 'server', + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'is_literal' => true, + ]); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + SharedEnvironmentVariable::where('type', 'server') + ->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME']) + ->delete(); + } +}; diff --git a/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php new file mode 100644 index 000000000..cd9d98a1c --- /dev/null +++ b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php @@ -0,0 +1,44 @@ +text('health_check_type')->default('http')->after('health_check_enabled'); + }); + } + + if (! Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->text('health_check_command')->nullable()->after('health_check_type'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('applications', 'health_check_type')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_type'); + }); + } + + if (Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_command'); + }); + } + } +}; diff --git a/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php b/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php new file mode 100644 index 000000000..76420fb5c --- /dev/null +++ b/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php @@ -0,0 +1,25 @@ +timestamp('stripe_refunded_at')->nullable()->after('stripe_past_due'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('stripe_refunded_at'); + }); + } +}; diff --git a/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php b/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php new file mode 100644 index 000000000..defebcce4 --- /dev/null +++ b/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php @@ -0,0 +1,60 @@ +integer('public_port_timeout')->nullable()->default(3600)->after('public_port'); + }); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tables = [ + 'standalone_postgresqls', + 'standalone_mysqls', + 'standalone_mariadbs', + 'standalone_redis', + 'standalone_mongodbs', + 'standalone_clickhouses', + 'standalone_keydbs', + 'standalone_dragonflies', + 'service_databases', + ]; + + foreach ($tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'public_port_timeout')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('public_port_timeout'); + }); + } + } + } +}; diff --git a/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php b/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php new file mode 100644 index 000000000..cea25c3ba --- /dev/null +++ b/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php @@ -0,0 +1,32 @@ +json('server_metadata')->nullable(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('servers', 'server_metadata')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('server_metadata'); + }); + } + } +}; diff --git a/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php new file mode 100644 index 000000000..a1f1d9ea1 --- /dev/null +++ b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php @@ -0,0 +1,30 @@ +boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git'); + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path'); + }); + } + + public function down(): void + { + Schema::table('local_file_volumes', function (Blueprint $table) { + $table->dropColumn('is_preview_suffix_enabled'); + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('is_preview_suffix_enabled'); + }); + } +}; diff --git a/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php new file mode 100644 index 000000000..6b4fb690d --- /dev/null +++ b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php @@ -0,0 +1,39 @@ +string('uuid')->nullable()->after('id'); + }); + + DB::table('local_persistent_volumes') + ->whereNull('uuid') + ->orderBy('id') + ->chunk(1000, function ($volumes) { + foreach ($volumes as $volume) { + DB::table('local_persistent_volumes') + ->where('id', $volume->id) + ->update(['uuid' => (string) new Cuid2]); + } + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->unique()->change(); + }); + } + + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } +}; diff --git a/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php new file mode 100644 index 000000000..a4a6988f2 --- /dev/null +++ b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php @@ -0,0 +1,39 @@ +chunkById(100, function ($clickhouses) { + foreach ($clickhouses as $clickhouse) { + $password = $clickhouse->clickhouse_admin_password; + + if (empty($password)) { + continue; + } + + // Skip if already encrypted (idempotent) + try { + Crypt::decryptString($password); + + continue; + } catch (Exception) { + // Not encrypted yet — encrypt it + } + + DB::table('standalone_clickhouses') + ->where('id', $clickhouse->id) + ->update(['clickhouse_admin_password' => Crypt::encryptString($password)]); + } + }); + } catch (Exception $e) { + echo 'Encrypting ClickHouse admin passwords failed.'; + echo $e->getMessage(); + } + } +} diff --git a/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php b/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php new file mode 100644 index 000000000..2dafa2737 --- /dev/null +++ b/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php @@ -0,0 +1,30 @@ +string('docker_registry_image_tag')->nullable()->after('docker_compose_domains'); + }); + + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->string('docker_registry_image_tag')->nullable()->after('pull_request_id'); + }); + } + + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('docker_registry_image_tag'); + }); + + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn('docker_registry_image_tag'); + }); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql new file mode 100644 index 000000000..edbc35db4 --- /dev/null +++ b/database/schema/testing-schema.sql @@ -0,0 +1,1754 @@ +-- Generated by: php artisan schema:generate-testing +-- Date: 2026-02-11 13:10:01 +-- Last migration: 2025_12_17_000002_add_restart_tracking_to_standalone_databases + +CREATE TABLE IF NOT EXISTS "activity_log" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "log_name" TEXT, + "description" TEXT NOT NULL, + "subject_type" TEXT, + "subject_id" INTEGER, + "causer_type" TEXT, + "causer_id" INTEGER, + "properties" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "event" TEXT, + "batch_uuid" TEXT +); + +CREATE TABLE IF NOT EXISTS "additional_destinations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" INTEGER NOT NULL, + "server_id" INTEGER NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "standalone_docker_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_deployment_queues" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" TEXT NOT NULL, + "deployment_uuid" TEXT NOT NULL, + "pull_request_id" INTEGER DEFAULT 0 NOT NULL, + "force_rebuild" INTEGER DEFAULT false NOT NULL, + "commit" TEXT DEFAULT 'HEAD' NOT NULL, + "status" TEXT DEFAULT 'queued' NOT NULL, + "is_webhook" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "logs" TEXT, + "current_process_id" TEXT, + "restart_only" INTEGER DEFAULT false NOT NULL, + "git_type" TEXT, + "server_id" INTEGER, + "application_name" TEXT, + "server_name" TEXT, + "deployment_url" TEXT, + "destination_id" TEXT, + "only_this_server" INTEGER DEFAULT false NOT NULL, + "rollback" INTEGER DEFAULT false NOT NULL, + "commit_message" TEXT, + "is_api" INTEGER DEFAULT false NOT NULL, + "build_server_id" INTEGER, + "horizon_job_id" TEXT, + "horizon_job_worker" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_previews" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "pull_request_id" INTEGER NOT NULL, + "pull_request_html_url" TEXT NOT NULL, + "pull_request_issue_comment_id" TEXT, + "fqdn" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "git_type" TEXT, + "docker_compose_domains" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_static" INTEGER DEFAULT false NOT NULL, + "is_git_submodules_enabled" INTEGER DEFAULT true NOT NULL, + "is_git_lfs_enabled" INTEGER DEFAULT true NOT NULL, + "is_auto_deploy_enabled" INTEGER DEFAULT true NOT NULL, + "is_force_https_enabled" INTEGER DEFAULT true NOT NULL, + "is_debug_enabled" INTEGER DEFAULT false NOT NULL, + "is_preview_deployments_enabled" INTEGER DEFAULT false NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_gpu_enabled" INTEGER DEFAULT false NOT NULL, + "gpu_driver" TEXT DEFAULT 'nvidia' NOT NULL, + "gpu_count" TEXT, + "gpu_device_ids" TEXT, + "gpu_options" TEXT, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "is_swarm_only_worker_nodes" INTEGER DEFAULT true NOT NULL, + "is_raw_compose_deployment_enabled" INTEGER DEFAULT false NOT NULL, + "is_build_server_enabled" INTEGER DEFAULT false NOT NULL, + "is_consistent_container_name_enabled" INTEGER DEFAULT false NOT NULL, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "custom_internal_name" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "is_env_sorting_enabled" INTEGER DEFAULT false NOT NULL, + "is_container_label_readonly_enabled" INTEGER DEFAULT true NOT NULL, + "is_preserve_repository_enabled" INTEGER DEFAULT false NOT NULL, + "disable_build_cache" INTEGER DEFAULT false NOT NULL, + "is_spa" INTEGER DEFAULT false NOT NULL, + "is_git_shallow_clone_enabled" INTEGER DEFAULT true NOT NULL, + "is_pr_deployments_public_enabled" INTEGER DEFAULT false NOT NULL, + "use_build_secrets" INTEGER DEFAULT false NOT NULL, + "inject_build_args_to_dockerfile" INTEGER DEFAULT true NOT NULL, + "include_source_commit_in_build" INTEGER DEFAULT false NOT NULL, + "docker_images_to_keep" INTEGER DEFAULT 2 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "repository_project_id" INTEGER, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "fqdn" TEXT, + "config_hash" TEXT, + "git_repository" TEXT NOT NULL, + "git_branch" TEXT NOT NULL, + "git_commit_sha" TEXT DEFAULT 'HEAD' NOT NULL, + "git_full_url" TEXT, + "docker_registry_image_name" TEXT, + "docker_registry_image_tag" TEXT, + "build_pack" TEXT NOT NULL, + "static_image" TEXT DEFAULT 'nginx:alpine' NOT NULL, + "install_command" TEXT, + "build_command" TEXT, + "start_command" TEXT, + "ports_exposes" TEXT NOT NULL, + "ports_mappings" TEXT, + "base_directory" TEXT DEFAULT '/' NOT NULL, + "publish_directory" TEXT, + "health_check_path" TEXT DEFAULT '/' NOT NULL, + "health_check_port" TEXT, + "health_check_host" TEXT DEFAULT 'localhost' NOT NULL, + "health_check_method" TEXT DEFAULT 'GET' NOT NULL, + "health_check_return_code" INTEGER DEFAULT 200 NOT NULL, + "health_check_scheme" TEXT DEFAULT 'http' NOT NULL, + "health_check_response_text" TEXT, + "health_check_interval" INTEGER DEFAULT 5 NOT NULL, + "health_check_timeout" INTEGER DEFAULT 5 NOT NULL, + "health_check_retries" INTEGER DEFAULT 10 NOT NULL, + "health_check_start_period" INTEGER DEFAULT 5 NOT NULL, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "preview_url_template" TEXT DEFAULT '{{pr_id}}.{{domain}}' NOT NULL, + "destination_type" TEXT, + "destination_id" INTEGER, + "source_type" TEXT, + "source_id" INTEGER, + "private_key_id" INTEGER, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "dockerfile" TEXT, + "health_check_enabled" INTEGER DEFAULT false NOT NULL, + "dockerfile_location" TEXT, + "custom_labels" TEXT, + "dockerfile_target_build" TEXT, + "manual_webhook_secret_github" TEXT, + "manual_webhook_secret_gitlab" TEXT, + "docker_compose_location" TEXT DEFAULT '/docker-compose.yaml', + "docker_compose" TEXT, + "docker_compose_raw" TEXT, + "docker_compose_domains" TEXT, + "deleted_at" TEXT, + "docker_compose_custom_start_command" TEXT, + "docker_compose_custom_build_command" TEXT, + "swarm_replicas" INTEGER DEFAULT 1 NOT NULL, + "swarm_placement_constraints" TEXT, + "manual_webhook_secret_bitbucket" TEXT, + "custom_docker_run_options" TEXT, + "post_deployment_command" TEXT, + "post_deployment_command_container" TEXT, + "pre_deployment_command" TEXT, + "pre_deployment_command_container" TEXT, + "watch_paths" TEXT, + "custom_healthcheck_found" INTEGER DEFAULT false NOT NULL, + "manual_webhook_secret_gitea" TEXT, + "redirect" TEXT DEFAULT 'both' NOT NULL, + "compose_parsing_version" TEXT DEFAULT '1' NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "custom_nginx_configuration" TEXT, + "custom_network_aliases" TEXT, + "is_http_basic_auth_enabled" INTEGER DEFAULT false NOT NULL, + "http_basic_auth_username" TEXT, + "http_basic_auth_password" TEXT, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_init_scripts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "script" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_provider_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "token" TEXT NOT NULL, + "name" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "discord_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "discord_enabled" INTEGER DEFAULT false NOT NULL, + "discord_webhook_url" TEXT, + "deployment_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_discord_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_discord_notifications" INTEGER DEFAULT true NOT NULL, + "discord_ping_enabled" INTEGER DEFAULT true NOT NULL, + "server_patch_discord_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_discord_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "docker_cleanup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "cleanup_log" TEXT, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "email_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "use_instance_email_settings" INTEGER DEFAULT false NOT NULL, + "deployment_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_email_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_email_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_email_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT, + "is_preview" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL, + "uuid" TEXT NOT NULL, + "order" INTEGER, + "is_required" INTEGER DEFAULT false NOT NULL, + "is_shared" INTEGER DEFAULT false NOT NULL, + "resourceable_type" TEXT, + "resourceable_id" INTEGER, + "is_runtime" INTEGER DEFAULT true NOT NULL, + "is_buildtime" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environments" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "failed_jobs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "connection" TEXT NOT NULL, + "queue" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "exception" TEXT NOT NULL, + "failed_at" TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "github_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "app_id" INTEGER, + "installation_id" INTEGER, + "client_id" TEXT, + "client_secret" TEXT, + "webhook_secret" TEXT, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "contents" TEXT, + "metadata" TEXT, + "pull_requests" TEXT, + "administration" TEXT +); + +CREATE TABLE IF NOT EXISTS "gitlab_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "app_id" INTEGER, + "app_secret" TEXT, + "oauth_id" INTEGER, + "group_name" TEXT, + "public_key" TEXT, + "webhook_token" TEXT, + "deploy_key_id" INTEGER, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "instance_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "public_ipv4" TEXT, + "public_ipv6" TEXT, + "fqdn" TEXT, + "public_port_min" INTEGER DEFAULT 9000 NOT NULL, + "public_port_max" INTEGER DEFAULT 9100 NOT NULL, + "do_not_track" INTEGER DEFAULT false NOT NULL, + "is_auto_update_enabled" INTEGER DEFAULT true NOT NULL, + "is_registration_enabled" INTEGER DEFAULT true NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "next_channel" INTEGER DEFAULT false NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "is_dns_validation_enabled" INTEGER DEFAULT true NOT NULL, + "custom_dns_servers" TEXT DEFAULT '1.1.1.1', + "instance_name" TEXT, + "is_api_enabled" INTEGER DEFAULT false NOT NULL, + "allowed_ips" TEXT, + "auto_update_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "update_check_frequency" TEXT DEFAULT '0 * * * *' NOT NULL, + "new_version_available" INTEGER DEFAULT false NOT NULL, + "instance_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "helper_version" TEXT DEFAULT '1.0.0' NOT NULL, + "disable_two_step_confirmation" INTEGER DEFAULT false NOT NULL, + "is_sponsorship_popup_enabled" INTEGER DEFAULT true NOT NULL, + "dev_helper_version" TEXT, + "is_wire_navigate_enabled" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_file_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "fs_path" TEXT NOT NULL, + "mount_path" TEXT, + "content" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_directory" INTEGER DEFAULT false NOT NULL, + "chown" TEXT, + "chmod" TEXT, + "is_based_on_git" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_persistent_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "mount_path" TEXT NOT NULL, + "host_path" TEXT, + "container_id" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "migrations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "migration" TEXT NOT NULL, + "batch" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "oauth_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "provider" TEXT NOT NULL, + "enabled" INTEGER DEFAULT false NOT NULL, + "client_id" TEXT, + "client_secret" TEXT, + "redirect_uri" TEXT, + "tenant" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "base_url" TEXT +); + +CREATE TABLE IF NOT EXISTS "password_reset_tokens" ( + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "personal_access_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "tokenable_type" TEXT NOT NULL, + "tokenable_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "token" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "abilities" TEXT, + "last_used_at" TEXT, + "expires_at" TEXT, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "private_keys" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "private_key" TEXT NOT NULL, + "is_git_related" INTEGER DEFAULT false NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "fingerprint" TEXT +); + +CREATE TABLE IF NOT EXISTS "project_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "projects" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "pushover_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "pushover_enabled" INTEGER DEFAULT false NOT NULL, + "pushover_user_key" TEXT, + "pushover_api_token" TEXT, + "deployment_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_pushover_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "s3_storages" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "region" TEXT DEFAULT 'us-east-1' NOT NULL, + "key" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "bucket" TEXT NOT NULL, + "endpoint" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_usable" INTEGER DEFAULT false NOT NULL, + "unusable_email_sent" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "size" TEXT, + "filename" TEXT, + "scheduled_database_backup_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "database_name" TEXT, + "finished_at" TEXT, + "local_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_uploaded" INTEGER +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backups" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "description" TEXT, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "save_s3" INTEGER DEFAULT true NOT NULL, + "frequency" TEXT NOT NULL, + "database_backup_retention_amount_locally" INTEGER DEFAULT 0 NOT NULL, + "database_type" TEXT NOT NULL, + "database_id" INTEGER NOT NULL, + "s3_storage_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "databases_to_backup" TEXT, + "dump_all" INTEGER DEFAULT false NOT NULL, + "database_backup_retention_days_locally" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_locally" REAL DEFAULT '0' NOT NULL, + "database_backup_retention_amount_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_days_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_s3" REAL DEFAULT '0' NOT NULL, + "timeout" INTEGER DEFAULT 3600 NOT NULL, + "disable_local_backup" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_task_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "scheduled_task_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT, + "started_at" TEXT, + "retry_count" INTEGER DEFAULT 0 NOT NULL, + "duration" REAL, + "error_details" TEXT +); + +CREATE TABLE IF NOT EXISTS "scheduled_tasks" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "name" TEXT NOT NULL, + "command" TEXT NOT NULL, + "frequency" TEXT NOT NULL, + "container" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "application_id" INTEGER, + "service_id" INTEGER, + "team_id" INTEGER NOT NULL, + "timeout" INTEGER DEFAULT 300 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "server_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_swarm_manager" INTEGER DEFAULT false NOT NULL, + "is_jump_server" INTEGER DEFAULT false NOT NULL, + "is_build_server" INTEGER DEFAULT false NOT NULL, + "is_reachable" INTEGER DEFAULT false NOT NULL, + "is_usable" INTEGER DEFAULT false NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "wildcard_domain" TEXT, + "is_cloudflare_tunnel" INTEGER DEFAULT false NOT NULL, + "is_logdrain_newrelic_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_newrelic_license_key" TEXT, + "logdrain_newrelic_base_uri" TEXT, + "is_logdrain_highlight_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_highlight_project_id" TEXT, + "is_logdrain_axiom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_axiom_dataset_name" TEXT, + "logdrain_axiom_api_key" TEXT, + "is_swarm_worker" INTEGER DEFAULT false NOT NULL, + "is_logdrain_custom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_custom_config" TEXT, + "logdrain_custom_config_parser" TEXT, + "concurrent_builds" INTEGER DEFAULT 2 NOT NULL, + "dynamic_timeout" INTEGER DEFAULT 3600 NOT NULL, + "force_disabled" INTEGER DEFAULT false NOT NULL, + "is_metrics_enabled" INTEGER DEFAULT false NOT NULL, + "generate_exact_labels" INTEGER DEFAULT false NOT NULL, + "force_docker_cleanup" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "docker_cleanup_threshold" INTEGER DEFAULT 80 NOT NULL, + "server_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "delete_unused_volumes" INTEGER DEFAULT false NOT NULL, + "delete_unused_networks" INTEGER DEFAULT false NOT NULL, + "is_sentinel_enabled" INTEGER DEFAULT true NOT NULL, + "sentinel_token" TEXT, + "sentinel_metrics_refresh_rate_seconds" INTEGER DEFAULT 10 NOT NULL, + "sentinel_metrics_history_days" INTEGER DEFAULT 7 NOT NULL, + "sentinel_push_interval_seconds" INTEGER DEFAULT 60 NOT NULL, + "sentinel_custom_url" TEXT, + "server_disk_usage_notification_threshold" INTEGER DEFAULT 80 NOT NULL, + "is_sentinel_debug_enabled" INTEGER DEFAULT false NOT NULL, + "server_disk_usage_check_frequency" TEXT DEFAULT '0 23 * * *' NOT NULL, + "is_terminal_enabled" INTEGER DEFAULT true NOT NULL, + "deployment_queue_limit" INTEGER DEFAULT 25 NOT NULL, + "disable_application_image_retention" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "servers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ip" TEXT NOT NULL, + "port" INTEGER DEFAULT 22 NOT NULL, + "user" TEXT DEFAULT 'root' NOT NULL, + "team_id" INTEGER NOT NULL, + "private_key_id" INTEGER NOT NULL, + "proxy" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "unreachable_notification_sent" INTEGER DEFAULT false NOT NULL, + "unreachable_count" INTEGER DEFAULT 0 NOT NULL, + "high_disk_usage_notification_sent" INTEGER DEFAULT false NOT NULL, + "log_drain_notification_sent" INTEGER DEFAULT false NOT NULL, + "swarm_cluster" INTEGER, + "validation_logs" TEXT, + "sentinel_updated_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT, + "ip_previous" TEXT, + "hetzner_server_id" INTEGER, + "cloud_provider_token_id" INTEGER, + "hetzner_server_status" TEXT, + "is_validating" INTEGER DEFAULT false NOT NULL, + "detected_traefik_version" TEXT, + "traefik_outdated_info" TEXT +); + +CREATE TABLE IF NOT EXISTS "service_applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "fqdn" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "required_fqdn" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "service_databases" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "public_port" INTEGER, + "is_public" INTEGER DEFAULT false NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL, + "custom_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "services" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "server_id" INTEGER, + "description" TEXT, + "docker_compose_raw" TEXT NOT NULL, + "docker_compose" TEXT, + "destination_type" TEXT, + "destination_id" INTEGER, + "deleted_at" TEXT, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "config_hash" TEXT, + "service_type" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "compose_parsing_version" TEXT DEFAULT '2' NOT NULL +); + +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" TEXT NOT NULL, + "user_id" INTEGER, + "ip_address" TEXT, + "user_agent" TEXT, + "payload" TEXT NOT NULL, + "last_activity" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "shared_environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "type" TEXT DEFAULT 'team' NOT NULL, + "team_id" INTEGER NOT NULL, + "project_id" INTEGER, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "slack_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "slack_enabled" INTEGER DEFAULT false NOT NULL, + "slack_webhook_url" TEXT, + "deployment_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_slack_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_slack_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_slack_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ssl_certificates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "ssl_certificate" TEXT NOT NULL, + "ssl_private_key" TEXT NOT NULL, + "configuration_dir" TEXT, + "mount_path" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "server_id" INTEGER NOT NULL, + "common_name" TEXT NOT NULL, + "subject_alternative_names" TEXT, + "valid_until" TEXT NOT NULL, + "is_ca_certificate" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_clickhouses" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "clickhouse_admin_user" TEXT DEFAULT 'default' NOT NULL, + "clickhouse_admin_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'clickhouse/clickhouse-server:25.11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "clickhouse_db" TEXT DEFAULT 'default' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "network" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dragonflies" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "dragonfly_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'docker.dragonflydb.io/dragonflydb/dragonfly' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_keydbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "keydb_password" TEXT NOT NULL, + "keydb_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'eqalpha/keydb:latest' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mariadbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mariadb_root_password" TEXT NOT NULL, + "mariadb_user" TEXT DEFAULT 'mariadb' NOT NULL, + "mariadb_password" TEXT NOT NULL, + "mariadb_database" TEXT DEFAULT 'default' NOT NULL, + "mariadb_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mariadb:11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mongodbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mongo_conf" TEXT, + "mongo_initdb_root_username" TEXT DEFAULT 'root' NOT NULL, + "mongo_initdb_root_password" TEXT NOT NULL, + "mongo_initdb_database" TEXT DEFAULT 'default' NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mongo:7' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mysqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mysql_root_password" TEXT NOT NULL, + "mysql_user" TEXT DEFAULT 'mysql' NOT NULL, + "mysql_password" TEXT NOT NULL, + "mysql_database" TEXT DEFAULT 'default' NOT NULL, + "mysql_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mysql:8' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'REQUIRED' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_postgresqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "postgres_user" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_password" TEXT NOT NULL, + "postgres_db" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_initdb_args" TEXT, + "postgres_host_auth_method" TEXT, + "init_scripts" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'postgres:16-alpine' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "postgres_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_redis" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "redis_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'redis:7.2' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "stripe_invoice_paid" INTEGER DEFAULT false NOT NULL, + "stripe_subscription_id" TEXT, + "stripe_customer_id" TEXT, + "stripe_cancel_at_period_end" INTEGER DEFAULT false NOT NULL, + "stripe_plan_id" TEXT, + "stripe_feedback" TEXT, + "stripe_comment" TEXT, + "stripe_trial_already_ended" INTEGER DEFAULT false NOT NULL, + "stripe_past_due" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "swarm_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "network" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "taggables" ( + "tag_id" INTEGER NOT NULL, + "taggable_id" INTEGER NOT NULL, + "taggable_type" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "tags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "team_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_invitations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "team_id" INTEGER NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "link" TEXT NOT NULL, + "via" TEXT DEFAULT 'link' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_user" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "teams" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "personal_team" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "show_boarding" INTEGER DEFAULT false NOT NULL, + "custom_server_limit" INTEGER +); + +CREATE TABLE IF NOT EXISTS "telegram_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "telegram_enabled" INTEGER DEFAULT false NOT NULL, + "telegram_token" TEXT, + "telegram_chat_id" TEXT, + "deployment_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_deployment_success_thread_id" TEXT, + "telegram_notifications_deployment_failure_thread_id" TEXT, + "telegram_notifications_status_change_thread_id" TEXT, + "telegram_notifications_backup_success_thread_id" TEXT, + "telegram_notifications_backup_failure_thread_id" TEXT, + "telegram_notifications_scheduled_task_success_thread_id" TEXT, + "telegram_notifications_scheduled_task_failure_thread_id" TEXT, + "telegram_notifications_docker_cleanup_success_thread_id" TEXT, + "telegram_notifications_docker_cleanup_failure_thread_id" TEXT, + "telegram_notifications_server_disk_usage_thread_id" TEXT, + "telegram_notifications_server_reachable_thread_id" TEXT, + "telegram_notifications_server_unreachable_thread_id" TEXT, + "server_patch_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_server_patch_thread_id" TEXT, + "telegram_notifications_traefik_outdated_thread_id" TEXT, + "traefik_outdated_telegram_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_entries" ( + "sequence" INTEGER NOT NULL, + "uuid" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "family_hash" TEXT, + "should_display_on_index" INTEGER DEFAULT true NOT NULL, + "type" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "telescope_entries_tags" ( + "entry_uuid" TEXT NOT NULL, + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_monitoring" ( + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "user_changelog_reads" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "user_id" INTEGER NOT NULL, + "release_tag" TEXT NOT NULL, + "read_at" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT DEFAULT 'Anonymous' NOT NULL, + "email" TEXT NOT NULL, + "email_verified_at" TEXT, + "password" TEXT, + "remember_token" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "two_factor_secret" TEXT, + "two_factor_recovery_codes" TEXT, + "two_factor_confirmed_at" TEXT, + "force_password_reset" INTEGER DEFAULT false NOT NULL, + "marketing_emails" INTEGER DEFAULT true NOT NULL, + "pending_email" TEXT, + "email_change_code" TEXT, + "email_change_code_expires_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "webhook_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "webhook_enabled" INTEGER DEFAULT false NOT NULL, + "webhook_url" TEXT, + "deployment_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "traefik_outdated_webhook_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE INDEX IF NOT EXISTS "activity_log_log_name_index" ON "activity_log" (log_name); +CREATE INDEX IF NOT EXISTS "causer" ON "activity_log" (causer_type, causer_id); +CREATE INDEX IF NOT EXISTS "subject" ON "activity_log" (subject_type, subject_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_deployment_queues_deployment_uuid_unique" ON "application_deployment_queues" (deployment_uuid); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_app_status_pr_created" ON "application_deployment_queues" (application_id, status, pull_request_id, created_at); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_status_server" ON "application_deployment_queues" (status, server_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_fqdn_unique" ON "application_previews" (fqdn); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_uuid_unique" ON "application_previews" (uuid); +CREATE INDEX IF NOT EXISTS "applications_destination_type_destination_id_index" ON "applications" (destination_type, destination_id); +CREATE INDEX IF NOT EXISTS "applications_source_type_source_id_index" ON "applications" (source_type, source_id); +CREATE UNIQUE INDEX IF NOT EXISTS "applications_uuid_unique" ON "applications" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_init_scripts_team_id" ON "cloud_init_scripts" (team_id); +CREATE INDEX IF NOT EXISTS "cloud_provider_tokens_team_id_provider_index" ON "cloud_provider_tokens" (team_id, provider); +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_provider_tokens_uuid_unique" ON "cloud_provider_tokens" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_provider_tokens_team_id" ON "cloud_provider_tokens" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "discord_notification_settings_team_id_unique" ON "discord_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "docker_cleanup_executions_uuid_unique" ON "docker_cleanup_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "email_notification_settings_team_id_unique" ON "email_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "environment_variables_resourceable_type_resourceable_id_index" ON "environment_variables" (resourceable_type, resourceable_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_name_project_id_unique" ON "environments" (name, project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_uuid_unique" ON "environments" (uuid); +CREATE INDEX IF NOT EXISTS "idx_environments_project_id" ON "environments" (project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "failed_jobs_uuid_unique" ON "failed_jobs" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "github_apps_uuid_unique" ON "github_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "gitlab_apps_uuid_unique" ON "gitlab_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "local_file_volumes_mount_path_resource_id_resource_type_unique" ON "local_file_volumes" (mount_path, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_file_volumes_resource_type_resource_id_index" ON "local_file_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "local_persistent_volumes_name_resource_id_resource_type_unique" ON "local_persistent_volumes" (name, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_persistent_volumes_resource_type_resource_id_index" ON "local_persistent_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "oauth_settings_provider_unique" ON "oauth_settings" (provider); +CREATE UNIQUE INDEX IF NOT EXISTS "personal_access_tokens_token_unique" ON "personal_access_tokens" (token); +CREATE INDEX IF NOT EXISTS "personal_access_tokens_tokenable_type_tokenable_id_index" ON "personal_access_tokens" (tokenable_type, tokenable_id); +CREATE INDEX IF NOT EXISTS "idx_private_keys_team_id" ON "private_keys" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "private_keys_uuid_unique" ON "private_keys" (uuid); +CREATE INDEX IF NOT EXISTS "idx_projects_team_id" ON "projects" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "projects_uuid_unique" ON "projects" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "pushover_notification_settings_team_id_unique" ON "pushover_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "s3_storages_uuid_unique" ON "s3_storages" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backup_executions_uuid_unique" ON "scheduled_database_backup_executions" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_db_backup_executions_backup_id_created_at_index" ON "scheduled_database_backup_executions" (scheduled_database_backup_id, created_at); +CREATE INDEX IF NOT EXISTS "scheduled_database_backups_database_type_database_id_index" ON "scheduled_database_backups" (database_type, database_id); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backups_uuid_unique" ON "scheduled_database_backups" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_task_executions_task_id_created_at_index" ON "scheduled_task_executions" (scheduled_task_id, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_task_executions_uuid_unique" ON "scheduled_task_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_tasks_uuid_unique" ON "scheduled_tasks" (uuid); +CREATE INDEX IF NOT EXISTS "idx_servers_team_id" ON "servers" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "servers_uuid_unique" ON "servers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_applications_uuid_unique" ON "service_applications" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_databases_uuid_unique" ON "service_databases" (uuid); +CREATE INDEX IF NOT EXISTS "services_destination_type_destination_id_index" ON "services" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "services_uuid_unique" ON "services" (uuid); +CREATE INDEX IF NOT EXISTS "sessions_last_activity_index" ON "sessions" (last_activity); +CREATE INDEX IF NOT EXISTS "sessions_user_id_index" ON "sessions" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_environment_id_team_id_unique" ON "shared_environment_variables" (key, environment_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_project_id_team_id_unique" ON "shared_environment_variables" (key, project_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "slack_notification_settings_team_id_unique" ON "slack_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "standalone_clickhouses_destination_type_destination_id_index" ON "standalone_clickhouses" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_clickhouses_uuid_unique" ON "standalone_clickhouses" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_server_id_network_unique" ON "standalone_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_uuid_unique" ON "standalone_dockers" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_dragonflies_destination_type_destination_id_index" ON "standalone_dragonflies" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dragonflies_uuid_unique" ON "standalone_dragonflies" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_keydbs_destination_type_destination_id_index" ON "standalone_keydbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_keydbs_uuid_unique" ON "standalone_keydbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mariadbs_destination_type_destination_id_index" ON "standalone_mariadbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mariadbs_uuid_unique" ON "standalone_mariadbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mongodbs_destination_type_destination_id_index" ON "standalone_mongodbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mongodbs_uuid_unique" ON "standalone_mongodbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mysqls_destination_type_destination_id_index" ON "standalone_mysqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mysqls_uuid_unique" ON "standalone_mysqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_postgresqls_destination_type_destination_id_index" ON "standalone_postgresqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_postgresqls_uuid_unique" ON "standalone_postgresqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_redis_destination_type_destination_id_index" ON "standalone_redis" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_redis_uuid_unique" ON "standalone_redis" (uuid); +CREATE INDEX IF NOT EXISTS "idx_subscriptions_team_id" ON "subscriptions" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_server_id_network_unique" ON "swarm_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_uuid_unique" ON "swarm_dockers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "taggable_unique" ON "taggables" (tag_id, taggable_id, taggable_type); +CREATE UNIQUE INDEX IF NOT EXISTS "tags_uuid_unique" ON "tags" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_team_id_email_unique" ON "team_invitations" (team_id, email); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_uuid_unique" ON "team_invitations" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_user_team_id_user_id_unique" ON "team_user" (team_id, user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_notification_settings_team_id_unique" ON "telegram_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_batch_id_index" ON "telescope_entries" (batch_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_created_at_index" ON "telescope_entries" (created_at); +CREATE INDEX IF NOT EXISTS "telescope_entries_family_hash_index" ON "telescope_entries" (family_hash); +CREATE INDEX IF NOT EXISTS "telescope_entries_type_should_display_on_index_index" ON "telescope_entries" (type, should_display_on_index); +CREATE UNIQUE INDEX IF NOT EXISTS "telescope_entries_uuid_unique" ON "telescope_entries" (uuid); +CREATE INDEX IF NOT EXISTS "telescope_entries_tags_tag_index" ON "telescope_entries_tags" (tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_changelog_reads" (release_tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag); +CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email); +CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id); + +-- Migration records +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (1, '2014_10_12_000000_create_users_table', 1); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (2, '2014_10_12_100000_create_password_reset_tokens_table', 2); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (3, '2014_10_12_200000_add_two_factor_columns_to_users_table', 3); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (4, '2018_08_08_100000_create_telescope_entries_table', 4); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (5, '2019_12_14_000001_create_personal_access_tokens_table', 5); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (6, '2023_03_20_112410_create_activity_log_table', 6); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (7, '2023_03_20_112411_add_event_column_to_activity_log_table', 7); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (8, '2023_03_20_112412_add_batch_uuid_column_to_activity_log_table', 8); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (9, '2023_03_20_112809_create_sessions_table', 9); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (10, '2023_03_20_112811_create_teams_table', 10); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (11, '2023_03_20_112812_create_team_user_table', 11); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (12, '2023_03_20_112813_create_team_invitations_table', 12); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (13, '2023_03_20_112814_create_instance_settings_table', 13); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (14, '2023_03_24_140711_create_servers_table', 14); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (15, '2023_03_24_140712_create_server_settings_table', 15); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (16, '2023_03_24_140853_create_private_keys_table', 16); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (17, '2023_03_27_075351_create_projects_table', 17); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (18, '2023_03_27_075443_create_project_settings_table', 18); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (19, '2023_03_27_075444_create_environments_table', 19); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (20, '2023_03_27_081716_create_applications_table', 20); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (21, '2023_03_27_081717_create_application_settings_table', 21); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (22, '2023_03_27_081718_create_application_previews_table', 22); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (23, '2023_03_27_083621_create_services_table', 23); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (24, '2023_03_27_085020_create_standalone_dockers_table', 24); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (25, '2023_03_27_085022_create_swarm_dockers_table', 25); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (26, '2023_03_28_062150_create_kubernetes_table', 26); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (27, '2023_03_28_083723_create_github_apps_table', 27); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (28, '2023_03_28_083726_create_gitlab_apps_table', 28); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (29, '2023_04_03_111012_create_local_persistent_volumes_table', 29); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (30, '2023_05_04_194548_create_environment_variables_table', 30); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (31, '2023_05_17_104039_create_failed_jobs_table', 31); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (32, '2023_05_24_083426_create_application_deployment_queues_table', 32); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (33, '2023_06_22_131459_move_wildcard_to_server', 33); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (34, '2023_06_23_084605_remove_wildcard_domain_from_instancesettings', 34); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (35, '2023_06_23_110548_next_channel_updates', 35); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (36, '2023_06_23_114131_change_env_var_value_length', 36); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (37, '2023_06_23_114132_remove_default_redirect_from_instance_settings', 37); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (38, '2023_06_23_114133_use_application_deployment_queues_as_activity', 38); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (39, '2023_06_23_114134_add_disk_usage_percentage_to_servers', 39); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (40, '2023_07_13_115117_create_subscriptions_table', 40); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (41, '2023_07_13_120719_create_webhooks_table', 41); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (42, '2023_07_13_120721_add_license_to_instance_settings', 42); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (43, '2023_07_27_182013_smtp_discord_schemaless_to_normal', 43); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (44, '2023_08_06_142951_add_description_field_to_applications_table', 44); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (45, '2023_08_06_142952_remove_foreignId_environment_variables', 45); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (46, '2023_08_06_142954_add_readonly_localpersistentvolumes', 46); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (47, '2023_08_07_073651_create_s3_storages_table', 47); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (48, '2023_08_07_142950_create_standalone_postgresqls_table', 48); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (49, '2023_08_08_150103_create_scheduled_database_backups_table', 49); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (50, '2023_08_10_113306_create_scheduled_database_backup_executions_table', 50); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (51, '2023_08_10_201311_add_backup_notifications_to_teams', 51); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (52, '2023_08_11_190528_add_dockerfile_to_applications_table', 52); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (53, '2023_08_15_095902_create_waitlists_table', 53); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (54, '2023_08_15_111125_update_users_table', 54); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (55, '2023_08_15_111126_update_servers_add_unreachable_count_table', 55); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (56, '2023_08_22_071048_add_boarding_to_teams', 56); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (57, '2023_08_22_071049_update_webhooks_type', 57); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (58, '2023_08_22_071050_update_subscriptions_stripe', 58); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (59, '2023_08_22_071051_add_stripe_plan_to_subscriptions', 59); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (60, '2023_08_22_071052_add_resend_as_email', 60); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (61, '2023_08_22_071053_add_resend_as_email_to_teams', 61); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (62, '2023_08_22_071054_add_stripe_reasons', 62); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (63, '2023_08_22_071055_add_discord_notifications_to_teams', 63); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (64, '2023_08_22_071056_update_telegram_notifications', 64); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (65, '2023_08_22_071057_add_nixpkgsarchive_to_applications', 65); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (66, '2023_08_22_071058_add_nixpkgsarchive_to_applications_remove', 66); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (67, '2023_08_22_071059_add_stripe_trial_ended', 67); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (68, '2023_08_22_071060_change_invitation_link_length', 68); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (69, '2023_09_20_082541_update_services_table', 69); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (70, '2023_09_20_082733_create_service_databases_table', 70); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (71, '2023_09_20_082737_create_service_applications_table', 71); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (72, '2023_09_20_083549_update_environment_variables_table', 72); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (73, '2023_09_22_185356_create_local_file_volumes_table', 73); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (74, '2023_09_23_111808_update_servers_with_cloudflared', 74); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (75, '2023_09_23_111809_remove_destination_from_services_table', 75); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (76, '2023_09_23_111811_update_service_applications_table', 76); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (77, '2023_09_23_111812_update_service_databases_table', 77); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (78, '2023_09_23_111813_update_users_databases_table', 78); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (79, '2023_09_23_111814_update_local_file_volumes_table', 79); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (80, '2023_09_23_111815_add_healthcheck_disable_to_apps_table', 80); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (81, '2023_09_23_111816_add_destination_to_services_table', 81); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (82, '2023_09_23_111817_use_instance_email_settings_by_default', 82); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (83, '2023_09_23_111818_set_notifications_on_by_default', 83); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (84, '2023_09_23_111819_add_server_emails', 84); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (85, '2023_10_08_111819_add_server_unreachable_count', 85); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (86, '2023_10_10_100320_update_s3_storages_table', 86); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (87, '2023_10_10_113144_add_dockerfile_location_applications_table', 87); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (88, '2023_10_12_132430_create_standalone_redis_table', 88); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (89, '2023_10_12_132431_add_standalone_redis_to_environment_variables_table', 89); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (90, '2023_10_12_132432_add_database_selection_to_backups', 90); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (91, '2023_10_18_072519_add_custom_labels_applications_table', 91); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (92, '2023_10_19_101331_create_standalone_mongodbs_table', 92); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (93, '2023_10_19_101332_add_standalone_mongodb_to_environment_variables_table', 93); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (94, '2023_10_24_103548_create_standalone_mysqls_table', 94); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (95, '2023_10_24_120523_create_standalone_mariadbs_table', 95); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (96, '2023_10_24_120524_add_standalone_mysql_to_environment_variables_table', 96); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (97, '2023_10_24_124934_add_is_shown_once_to_environment_variables_table', 97); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (98, '2023_11_01_100437_add_restart_to_deployment_queue', 98); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (99, '2023_11_07_123731_add_target_build_dockerfile', 99); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (100, '2023_11_08_112815_add_custom_config_standalone_postgresql', 100); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (101, '2023_11_09_133332_add_public_port_to_service_databases', 101); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (102, '2023_11_12_180605_change_fqdn_to_longer_field', 102); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (103, '2023_11_13_133059_add_sponsorship_disable', 103); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (104, '2023_11_14_103450_add_manual_webhook_secret', 104); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (105, '2023_11_14_121416_add_git_type', 105); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (106, '2023_11_16_101819_add_high_disk_usage_notification', 106); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (107, '2023_11_16_220647_add_log_drains', 107); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (108, '2023_11_17_160437_add_drain_log_enable_by_service', 108); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (109, '2023_11_20_094628_add_gpu_settings', 109); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (110, '2023_11_21_121920_add_additional_destinations_to_apps', 110); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (111, '2023_11_24_080341_add_docker_compose_location', 111); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (112, '2023_11_28_143533_add_fields_to_swarm_dockers', 112); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (113, '2023_11_29_075937_change_swarm_properties', 113); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (114, '2023_12_01_091723_save_logs_view_settings', 114); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (115, '2023_12_01_095356_add_custom_fluentd_config_for_logdrains', 115); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (116, '2023_12_08_162228_add_soft_delete_services', 116); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (117, '2023_12_11_103611_add_realtime_connection_problem', 117); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (118, '2023_12_13_110214_add_soft_deletes', 118); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (119, '2023_12_17_155616_add_custom_docker_compose_start_command', 119); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (120, '2023_12_18_093514_add_swarm_related_things', 120); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (121, '2023_12_19_124111_add_swarm_cluster_grouping', 121); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (122, '2023_12_30_134507_add_description_to_environments', 122); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (123, '2023_12_31_173041_create_scheduled_tasks_table', 123); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (124, '2024_01_01_231053_create_scheduled_task_executions_table', 124); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (125, '2024_01_02_113855_add_raw_compose_deployment', 125); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (126, '2024_01_12_123422_update_cpuset_limits', 126); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (127, '2024_01_15_084609_add_custom_dns_server', 127); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (128, '2024_01_16_115005_add_build_server_enable', 128); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (129, '2024_01_21_130328_add_docker_network_to_services', 129); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (130, '2024_01_23_095832_add_manual_webhook_secret_bitbucket', 130); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (131, '2024_01_23_113129_create_shared_environment_variables_table', 131); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (132, '2024_01_24_095449_add_concurrent_number_of_builds_per_server', 132); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (133, '2024_01_25_073212_add_server_id_to_queues', 133); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (134, '2024_01_27_164724_add_application_name_and_deployment_url_to_queue', 134); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (135, '2024_01_29_072322_change_env_variable_length', 135); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (136, '2024_01_29_145200_add_custom_docker_run_options', 136); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (137, '2024_02_01_111228_create_tags_table', 137); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (138, '2024_02_05_105215_add_destination_to_app_deployments', 138); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (139, '2024_02_06_132748_add_additional_destinations', 139); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (140, '2024_02_08_075523_add_post_deployment_to_applications', 140); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (141, '2024_02_08_112304_add_dynamic_timeout_for_deployments', 141); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (142, '2024_02_15_101921_add_consistent_application_container_name', 142); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (143, '2024_02_15_192025_add_is_gzip_enabled_to_services', 143); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (144, '2024_02_20_165045_add_permissions_to_github_app', 144); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (145, '2024_02_22_090900_add_only_this_server_deployment', 145); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (146, '2024_02_23_143119_add_custom_server_limits_to_teams_ultimate', 146); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (147, '2024_02_25_222150_add_server_force_disabled_field', 147); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (148, '2024_03_04_092244_add_gzip_enabled_and_stripprefix_settings', 148); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (149, '2024_03_07_115054_add_notifications_notification_disable', 149); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (150, '2024_03_08_180457_nullable_password', 150); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (151, '2024_03_11_150013_create_oauth_settings', 151); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (152, '2024_03_14_214402_add_multiline_envs', 152); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (153, '2024_03_18_101440_add_version_of_envs', 153); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (154, '2024_03_22_080914_remove_popup_notifications', 154); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (155, '2024_03_26_122110_remove_realtime_notifications', 155); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (156, '2024_03_28_114620_add_watch_paths_to_apps', 156); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (157, '2024_04_09_095517_make_custom_docker_commands_longer', 157); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (158, '2024_04_10_071920_create_standalone_keydbs_table', 158); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (159, '2024_04_10_082220_create_standalone_dragonflies_table', 159); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (160, '2024_04_10_091519_create_standalone_clickhouses_table', 160); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (161, '2024_04_10_124015_add_permission_local_file_volumes', 161); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (162, '2024_04_12_092337_add_config_hash_to_other_resources', 162); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (163, '2024_04_15_094703_add_literal_variables', 163); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (164, '2024_04_16_083919_add_service_type_on_creation', 164); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (165, '2024_04_17_132541_add_rollback_queues', 165); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (166, '2024_04_25_073615_add_docker_network_to_application_settings', 166); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (167, '2024_04_29_111956_add_custom_hc_indicator_apps', 167); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (168, '2024_05_06_093236_add_custom_name_to_application_settings', 168); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (169, '2024_05_07_124019_add_server_metrics', 169); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (170, '2024_05_10_085215_make_stripe_comment_longer', 170); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (171, '2024_05_15_091757_add_commit_message_to_app_deployment_queue', 171); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (172, '2024_05_15_151236_add_container_escape_toggle', 172); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (173, '2024_05_17_082012_add_env_sorting_toggle', 173); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (174, '2024_05_21_125739_add_scheduled_tasks_notification_to_teams', 174); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (175, '2024_05_22_103942_change_pre_post_deployment_commands_length_in_applications', 175); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (176, '2024_05_23_091713_add_gitea_webhook_to_applications', 176); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (177, '2024_06_05_101019_add_docker_compose_pr_domains', 177); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (178, '2024_06_06_103938_change_pr_issue_commend_id_type', 178); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (179, '2024_06_11_081614_add_www_non_www_redirect', 179); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (180, '2024_06_18_105948_move_server_metrics', 180); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (181, '2024_06_20_102551_add_server_api_sentinel', 181); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (182, '2024_06_21_143358_add_api_deployment_type', 182); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (183, '2024_06_22_081140_alter_instance_settings_add_instance_name', 183); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (184, '2024_06_25_184323_update_db', 184); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (185, '2024_07_01_115528_add_is_api_allowed_and_iplist', 185); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (186, '2024_07_05_120217_remove_unique_from_tag_names', 186); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (187, '2024_07_11_083719_application_compose_versions', 187); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (188, '2024_07_17_123828_add_is_container_labels_readonly', 188); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (189, '2024_07_18_110424_create_application_settings_is_preserve_repository_enabled', 189); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (190, '2024_07_18_123458_add_force_cleanup_server', 190); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (191, '2024_07_19_132617_disable_healtcheck_by_default', 191); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (192, '2024_07_23_112710_add_validation_logs_to_servers', 192); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (193, '2024_08_05_142659_add_update_frequency_settings', 193); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (194, '2024_08_07_155324_add_proxy_label_chooser', 194); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (195, '2024_08_09_215659_add_server_cleanup_fields_to_server_settings_table', 195); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (196, '2024_08_12_131659_add_local_file_volume_based_on_git', 196); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (197, '2024_08_12_155023_add_timezone_to_server_and_instance_settings', 197); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (198, '2024_08_14_183120_add_order_to_environment_variables_table', 198); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (199, '2024_08_15_115907_add_build_server_id_to_deployment_queue', 199); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (200, '2024_08_16_105649_add_custom_docker_options_to_dbs', 200); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (201, '2024_08_27_090528_add_compose_parsing_version_to_services', 201); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (202, '2024_09_05_085700_add_helper_version_to_instance_settings', 202); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (203, '2024_09_06_062534_change_server_cleanup_to_forced', 203); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (204, '2024_09_07_185402_change_cleanup_schedule', 204); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (205, '2024_09_08_130756_update_server_settings_default_timezone', 205); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (206, '2024_09_16_111428_encrypt_existing_private_keys', 206); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (207, '2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table', 207); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (208, '2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table', 208); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (209, '2024_09_26_083441_disable_api_by_default', 209); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (210, '2024_10_03_095427_add_dump_all_to_standalone_postgresqls', 210); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (211, '2024_10_10_081444_remove_constraint_from_service_applications_fqdn', 211); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (212, '2024_10_11_114331_add_required_env_variables', 212); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (213, '2024_10_14_090416_update_metrics_token_in_server_settings', 213); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (214, '2024_10_15_172139_add_is_shared_to_environment_variables', 214); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (215, '2024_10_16_120026_move_redis_password_to_envs', 215); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (216, '2024_10_16_192133_add_confirmation_settings_to_instance_settings_table', 216); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (217, '2024_10_17_093722_add_soft_delete_to_servers', 217); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (218, '2024_10_22_105745_add_server_disk_usage_threshold', 218); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (219, '2024_10_22_121223_add_server_disk_usage_notification', 219); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (220, '2024_10_29_093927_add_is_sentinel_debug_enabled_to_server_settings', 220); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (221, '2024_10_30_074601_rename_token_permissions', 221); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (222, '2024_11_02_213214_add_last_online_at_to_resources', 222); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (223, '2024_11_11_125335_add_custom_nginx_configuration_to_static', 223); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (224, '2024_11_11_125366_add_index_to_activity_log', 224); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (225, '2024_11_22_124742_add_uuid_to_environments_table', 225); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (226, '2024_12_05_091823_add_disable_build_cache_advanced_option', 226); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (227, '2024_12_05_212355_create_email_notification_settings_table', 227); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (228, '2024_12_05_212416_create_discord_notification_settings_table', 228); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (229, '2024_12_05_212440_create_telegram_notification_settings_table', 229); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (230, '2024_12_05_212546_migrate_email_notification_settings_from_teams_table', 230); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (231, '2024_12_05_212631_migrate_discord_notification_settings_from_teams_table', 231); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (232, '2024_12_05_212705_migrate_telegram_notification_settings_from_teams_table', 232); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (233, '2024_12_06_142014_create_slack_notification_settings_table', 233); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (234, '2024_12_09_105711_drop_waitlists_table', 234); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (235, '2024_12_10_122142_encrypt_instance_settings_email_columns', 235); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (236, '2024_12_10_122143_drop_resale_license', 236); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (237, '2024_12_11_135026_create_pushover_notification_settings_table', 237); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (238, '2024_12_11_161418_add_authentik_base_url_to_oauth_settings_table', 238); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (239, '2024_12_13_103007_encrypt_resend_api_key_in_instance_settings', 239); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (240, '2024_12_16_134437_add_resourceable_columns_to_environment_variables_table', 240); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (241, '2024_12_17_140637_add_server_disk_usage_check_frequency_to_server_settings_table', 241); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (242, '2024_12_23_142402_update_email_encryption_values', 242); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (243, '2025_01_05_050736_add_network_aliases_to_applications_table', 243); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (244, '2025_01_08_154008_switch_up_readonly_labels', 244); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (245, '2025_01_10_135244_add_horizon_job_details_to_queue', 245); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (246, '2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table', 246); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (247, '2025_01_15_130416_create_docker_cleanup_executions_table', 247); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (248, '2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues', 248); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (249, '2025_01_16_130238_add_finished_at_to_executions_tables', 249); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (250, '2025_01_21_125205_update_finished_at_timestamps_if_not_set', 250); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (251, '2025_01_22_101105_remove_wrongly_created_envs', 251); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (252, '2025_01_27_102616_add_ssl_fields_to_database_tables', 252); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (253, '2025_01_27_153741_create_ssl_certificates_table', 253); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (254, '2025_01_30_125223_encrypt_local_file_volumes_fields', 254); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (255, '2025_02_27_125249_add_index_to_scheduled_task_executions', 255); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (256, '2025_03_01_112617_add_stripe_past_due', 256); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (257, '2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions', 257); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (258, '2025_03_21_104103_disable_discord_here', 258); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (259, '2025_03_26_104103_disable_mongodb_ssl_by_default', 259); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (260, '2025_03_29_204400_revert_some_local_volume_encryption', 260); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (261, '2025_03_31_124212_add_specific_spa_configuration', 261); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (262, '2025_04_01_124212_stripe_comment_nullable', 262); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (263, '2025_04_17_110026_add_application_http_basic_auth_fields', 263); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (264, '2025_04_30_134146_add_is_migrated_to_services', 264); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (265, '2025_05_26_100258_add_server_patch_notifications', 265); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (266, '2025_05_29_100258_add_terminal_enabled_to_server_settings', 266); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (267, '2025_06_06_073345_create_server_previous_ip', 267); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (268, '2025_06_16_123532_change_sentinel_on_by_default', 268); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (269, '2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table', 269); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (270, '2025_06_26_131350_optimize_activity_log_indexes', 270); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (271, '2025_07_14_191016_add_deleted_at_to_application_previews_table', 271); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (272, '2025_07_16_202201_add_timeout_to_scheduled_database_backups_table', 272); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (273, '2025_08_07_142403_create_user_changelog_reads_table', 273); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (274, '2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table', 274); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (275, '2025_08_18_104146_add_email_change_fields_to_users_table', 275); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (276, '2025_08_18_154244_change_env_sorting_default_to_false', 276); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (277, '2025_08_21_080234_add_git_shallow_clone_to_application_settings_table', 277); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (278, '2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings', 278); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (279, '2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table', 279); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (280, '2025_09_10_173300_drop_webhooks_table', 280); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (281, '2025_09_10_173402_drop_kubernetes_table', 281); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (282, '2025_09_11_143432_remove_is_build_time_from_environment_variables_table', 282); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (283, '2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table', 283); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (284, '2025_09_17_081112_add_use_build_secrets_to_application_settings', 284); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (285, '2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table', 285); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (286, '2025_10_03_154100_update_clickhouse_image', 286); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (287, '2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table', 287); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (288, '2025_10_08_181125_create_cloud_provider_tokens_table', 288); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (289, '2025_10_08_185203_add_hetzner_server_id_to_servers_table', 289); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (290, '2025_10_09_095905_add_cloud_provider_token_id_to_servers_table', 290); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (291, '2025_10_09_113602_add_hetzner_server_status_to_servers_table', 291); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (292, '2025_10_09_125036_add_is_validating_to_servers_table', 292); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (293, '2025_11_02_161923_add_dev_helper_version_to_instance_settings', 293); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (294, '2025_11_09_000001_add_timeout_to_scheduled_tasks_table', 294); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (295, '2025_11_09_000002_improve_scheduled_task_executions_tracking', 295); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (296, '2025_11_10_112500_add_restart_tracking_to_applications_table', 296); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (297, '2025_11_12_130931_add_traefik_version_tracking_to_servers_table', 297); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (298, '2025_11_12_131252_add_traefik_outdated_to_email_notification_settings', 298); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (299, '2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings', 299); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (300, '2025_11_14_114632_add_traefik_outdated_info_to_servers_table', 300); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (301, '2025_11_16_000001_create_webhook_notification_settings_table', 301); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (302, '2025_11_16_000002_create_cloud_init_scripts_table', 302); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (303, '2025_11_17_092707_add_traefik_outdated_to_notification_settings', 303); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (304, '2025_11_18_083747_cleanup_dockerfile_data_for_non_dockerfile_buildpacks', 304); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (305, '2025_11_26_124200_add_build_cache_settings_to_application_settings', 305); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (306, '2025_11_28_000001_migrate_clickhouse_to_official_image', 306); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (307, '2025_12_04_134435_add_deployment_queue_limit_to_server_settings', 307); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (308, '2025_12_05_000000_add_docker_images_to_keep_to_application_settings', 308); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (309, '2025_12_05_100000_add_disable_application_image_retention_to_server_settings', 309); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (310, '2025_12_08_135600_add_performance_indexes', 310); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_135600_add_uuid_to_cloud_provider_tokens', 311); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f5a00fe15..2a0273e0f 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\StandaloneDocker; use Illuminate\Database\Seeder; @@ -21,7 +22,7 @@ public function run(): void 'git_repository' => 'coollabsio/coolify-examples', 'git_branch' => 'v4.x', 'base_directory' => '/docker-compose', - 'docker_compose_location' => 'docker-compose-test.yaml', + 'docker_compose_location' => '/docker-compose-test.yaml', 'build_pack' => 'dockercompose', 'ports_exposes' => '80', 'environment_id' => 1, @@ -98,5 +99,51 @@ public function run(): void CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); + Application::create([ + 'uuid' => 'github-deploy-key', + 'name' => 'GitHub Deploy Key Example', + 'fqdn' => 'http://github-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + 'private_key_id' => 1, + ]); + Application::create([ + 'uuid' => 'gitlab-deploy-key', + 'name' => 'GitLab Deploy Key Example', + 'fqdn' => 'http://gitlab-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'private_key_id' => 1, + ]); + Application::create([ + 'uuid' => 'gitlab-public-example', + 'name' => 'GitLab Public Example', + 'fqdn' => 'http://gitlab-public.127.0.0.1.sslip.io', + 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', + 'base_directory' => '/astro/static', + 'publish_directory' => '/dist', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 8e439fd16..87236df8a 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -15,5 +15,12 @@ public function run(): void $application_1 = Application::find(1)->load(['settings']); $application_1->settings->is_debug_enabled = false; $application_1->settings->save(); + + $gitlabPublic = Application::where('uuid', 'gitlab-public-example')->first(); + if ($gitlabPublic) { + $gitlabPublic->load(['settings']); + $gitlabPublic->settings->is_static = true; + $gitlabPublic->settings->save(); + } } } diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php index 1b71a5e43..5d092d2e8 100644 --- a/database/seeders/CaSslCertSeeder.php +++ b/database/seeders/CaSslCertSeeder.php @@ -26,12 +26,14 @@ public function run() } $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($caCert->ssl_certificate); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php index e3968a1c9..c4e93af63 100644 --- a/database/seeders/RootUserSeeder.php +++ b/database/seeders/RootUserSeeder.php @@ -45,12 +45,13 @@ public function run(): void } try { - User::create([ + $user = (new User)->forceFill([ 'id' => 0, 'name' => env('ROOT_USERNAME', 'Root User'), 'email' => env('ROOT_USER_EMAIL'), 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]); + $user->save(); echo "\n SUCCESS Root user created successfully.\n\n"; } catch (\Exception $e) { echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n"; diff --git a/database/seeders/SharedEnvironmentVariableSeeder.php b/database/seeders/SharedEnvironmentVariableSeeder.php index 54643fe3b..7a17fbd10 100644 --- a/database/seeders/SharedEnvironmentVariableSeeder.php +++ b/database/seeders/SharedEnvironmentVariableSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Server; use App\Models\SharedEnvironmentVariable; use Illuminate\Database\Seeder; @@ -32,5 +33,29 @@ public function run(): void 'project_id' => 1, 'team_id' => 0, ]); + + // Add predefined server variables to all existing servers + $servers = \App\Models\Server::all(); + foreach ($servers as $server) { + SharedEnvironmentVariable::firstOrCreate([ + 'key' => 'COOLIFY_SERVER_UUID', + 'type' => 'server', + 'server_id' => $server->id, + 'team_id' => $server->team_id, + ], [ + 'value' => $server->uuid, + 'is_literal' => true, + ]); + + SharedEnvironmentVariable::firstOrCreate([ + 'key' => 'COOLIFY_SERVER_NAME', + 'type' => 'server', + 'server_id' => $server->id, + 'team_id' => $server->team_id, + ], [ + 'value' => $server->name, + 'is_literal' => true, + ]); + } } } diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml new file mode 100644 index 000000000..bbb483d7a --- /dev/null +++ b/docker-compose-maxio.dev.yml @@ -0,0 +1,211 @@ +services: + coolify: + image: coolify:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/development/Dockerfile + args: + - USER_ID=${USERID:-1000} + - GROUP_ID=${GROUPID:-1000} + ports: + - "${APP_PORT:-8000}:8080" + environment: + AUTORUN_ENABLED: false + PUSHER_HOST: "${PUSHER_HOST}" + PUSHER_PORT: "${PUSHER_PORT}" + PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" + PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" + PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: curl -sf http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: + - .:/var/www/html/:cached + - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify + postgres: + pull_policy: always + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + env_file: + - .env + environment: + POSTGRES_USER: "${DB_USERNAME:-coolify}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_postgres_data:/var/lib/postgresql/data + redis: + pull_policy: always + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + env_file: + - .env + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_redis_data:/data + soketi: + image: coolify-realtime:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile + env_file: + - .env + ports: + - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js + environment: + SOKETI_DEBUG: "false" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] + interval: 5s + retries: 10 + timeout: 2s + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] + vite: + image: node:24-alpine + pull_policy: always + container_name: coolify-vite + working_dir: /var/www/html + environment: + VITE_HOST: "${VITE_HOST:-localhost}" + VITE_PORT: "${VITE_PORT:-5173}" + ports: + - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + volumes: + - .:/var/www/html/:cached + command: sh -c "npm install && npm run dev" + networks: + - coolify + testing-host: + image: coolify-testing-host:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/testing-host/Dockerfile + init: true + container_name: coolify-testing-host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dev_coolify_data:/data/coolify + - dev_backups_data:/data/coolify/backups + - dev_postgres_data:/data/coolify/_volumes/database + - dev_redis_data:/data/coolify/_volumes/redis + - dev_minio_data:/data/coolify/_volumes/minio + networks: + - coolify + mailpit: + image: axllent/mailpit:latest + pull_policy: always + container_name: coolify-mail + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - coolify + # maxio: + # image: ghcr.io/coollabsio/maxio + # pull_policy: always + # container_name: coolify-maxio + # ports: + # - "${FORWARD_MAXIO_PORT:-9000}:9000" + # environment: + # MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}" + # MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}" + # volumes: + # - dev_maxio_data:/data + # networks: + # - coolify + minio: + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + pull_policy: always + container_name: coolify-minio + command: server /data --console-address ":9001" + ports: + - "${FORWARD_MINIO_PORT:-9000}:9000" + - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + environment: + MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" + volumes: + - dev_minio_data:/data + - dev_maxio_data:/data + networks: + - coolify + # maxio-init: + # image: minio/mc:latest + # pull_policy: always + # container_name: coolify-maxio-init + # restart: no + # depends_on: + # - maxio + # entrypoint: > + # /bin/sh -c " + # echo 'Waiting for MaxIO to be ready...'; + # until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do + # echo 'MaxIO not ready yet, waiting...'; + # sleep 2; + # done; + # echo 'MaxIO is ready, creating bucket if needed...'; + # mc mb local/local --ignore-existing; + # echo 'MaxIO initialization complete - bucket local is ready'; + # " + # networks: + # - coolify + minio-init: + image: minio/mc:latest + pull_policy: always + container_name: coolify-minio-init + restart: no + depends_on: + - minio + entrypoint: > + /bin/sh -c " + echo 'Waiting for MinIO to be ready...'; + until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do + echo 'MinIO not ready yet, waiting...'; + sleep 2; + done; + echo 'MinIO is ready, creating bucket if needed...'; + mc mb local/local --ignore-existing; + echo 'MinIO initialization complete - bucket local is ready'; + " + networks: + - coolify + +volumes: + dev_backups_data: + dev_postgres_data: + dev_redis_data: + dev_coolify_data: + dev_minio_data: + dev_maxio_data: + +networks: + coolify: + name: coolify + external: false diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e4c6b8b1..f608fe3cb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,6 +26,8 @@ services: volumes: - .:/var/www/html/:cached - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify postgres: pull_policy: always ports: @@ -71,11 +73,13 @@ services: volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js environment: SOKETI_DEBUG: "false" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] interval: 5s @@ -108,6 +112,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - dev_coolify_data:/data/coolify + - dev_coolify_data:/var/lib/docker/volumes/coolify_dev_coolify_data/_data - dev_backups_data:/data/coolify/backups - dev_postgres_data:/data/coolify/_volumes/database - dev_redis_data:/data/coolify/_volumes/redis diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 46e0e88e5..901aeb833 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" @@ -72,6 +72,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 3116a4185..998d35974 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' pull_policy: always container_name: coolify-realtime restart: always @@ -113,6 +113,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 14879eb96..9c984a5ee 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -28,7 +28,8 @@ ARG NIXPACKS_VERSION USER root WORKDIR /artifacts -RUN apk add --no-cache bash curl git git-lfs openssh-client tar tini +RUN apk upgrade --no-cache && \ + apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \ diff --git a/docker/coolify-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile index 18c2f9301..325a30dcc 100644 --- a/docker/coolify-realtime/Dockerfile +++ b/docker/coolify-realtime/Dockerfile @@ -10,12 +10,14 @@ ARG TARGETPLATFORM ARG CLOUDFLARED_VERSION WORKDIR /terminal -RUN apk add --no-cache openssh-client make g++ python3 curl +RUN apk upgrade --no-cache && \ + apk add --no-cache openssh-client make g++ python3 curl COPY docker/coolify-realtime/package.json ./ RUN npm i RUN npm rebuild node-pty --update-binary COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js +COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js # Install Cloudflared based on architecture RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index c445c972c..174077562 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -5,29 +5,29 @@ "packages": { "": { "dependencies": { - "@xterm/addon-fit": "0.10.0", - "@xterm/xterm": "5.5.0", - "axios": "1.12.0", - "cookie": "1.0.2", - "dotenv": "16.5.0", - "node-pty": "1.0.0", - "ws": "8.18.1" + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", + "axios": "1.15.0", + "cookie": "1.1.1", + "dotenv": "17.3.1", + "node-pty": "1.1.0", + "ws": "8.19.0" } }, "node_modules/@xterm/addon-fit": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", - "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] }, "node_modules/asynckit": { "version": "0.4.0", @@ -36,14 +36,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/call-bind-apply-helpers": { @@ -72,12 +72,16 @@ } }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/delayed-stream": { @@ -90,9 +94,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -161,9 +165,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==", "funding": [ { "type": "individual", @@ -181,9 +185,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -323,32 +327,35 @@ "node": ">= 0.6" } }, - "node_modules/nan": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", - "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, "node_modules/node-pty": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", - "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "nan": "^2.17.0" + "node-addon-api": "^7.1.0" } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index aec3dbe3d..30bfbcef7 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -2,12 +2,12 @@ "private": true, "type": "module", "dependencies": { - "@xterm/addon-fit": "0.10.0", - "@xterm/xterm": "5.5.0", - "cookie": "1.0.2", - "axios": "1.12.0", - "dotenv": "16.5.0", - "node-pty": "1.0.0", - "ws": "8.18.1" + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", + "cookie": "1.1.1", + "axios": "1.15.0", + "dotenv": "17.3.1", + "node-pty": "1.1.0", + "ws": "8.19.0" } } \ No newline at end of file diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 2607d2aec..3ae77857f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -4,8 +4,33 @@ import pty from 'node-pty'; import axios from 'axios'; import cookie from 'cookie'; import 'dotenv/config'; +import { + extractHereDocContent, + extractSshArgs, + extractTargetHost, + extractTimeout, + isAuthorizedTargetHost, +} from './terminal-utils.js'; const userSessions = new Map(); +const terminalDebugEnabled = ['local', 'development'].includes( + String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase() +); + +function logTerminal(level, message, context = {}) { + if (!terminalDebugEnabled) { + return; + } + + const formattedMessage = `[TerminalServer] ${message}`; + + if (Object.keys(context).length > 0) { + console[level](formattedMessage, context); + return; + } + + console[level](formattedMessage); +} const server = http.createServer((req, res) => { if (req.url === '/ready') { @@ -31,9 +56,19 @@ const getSessionCookie = (req) => { const verifyClient = async (info, callback) => { const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req); + const requestContext = { + remoteAddress: info.req.socket?.remoteAddress, + origin: info.origin, + sessionCookieName, + hasXsrfToken: Boolean(xsrfToken), + hasLaravelSession: Boolean(laravelSession), + }; + + logTerminal('log', 'Verifying websocket client.', requestContext); // Verify presence of required tokens if (!laravelSession || !xsrfToken) { + logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext); return callback(false, 401, 'Unauthorized: Missing required tokens'); } @@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => { }); if (response.status === 200) { - // Authentication successful + logTerminal('log', 'Websocket client authentication succeeded.', requestContext); callback(true); } else { + logTerminal('warn', 'Websocket client authentication returned a non-success status.', { + ...requestContext, + status: response.status, + }); callback(false, 401, 'Unauthorized: Invalid credentials'); } } catch (error) { - console.error('Authentication error:', error.message); + logTerminal('error', 'Websocket client authentication failed.', { + ...requestContext, + error: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); callback(false, 500, 'Internal Server Error'); } }; @@ -65,28 +109,62 @@ wss.on('connection', async (ws, req) => { const userId = generateUserId(); const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); + const connectionContext = { + userId, + remoteAddress: req.socket?.remoteAddress, + sessionCookieName, + hasXsrfToken: Boolean(xsrfToken), + hasLaravelSession: Boolean(laravelSession), + }; // Verify presence of required tokens if (!laravelSession || !xsrfToken) { + logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); ws.close(401, 'Unauthorized: Missing required tokens'); return; } - const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { - headers: { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }, - }); - userSession.authorizedIPs = response.data.ipAddresses || []; + + try { + const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { + headers: { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken + }, + }); + userSession.authorizedIPs = response.data.ipAddresses || []; + logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', { + ...connectionContext, + authorizedIPs: userSession.authorizedIPs, + }); + } catch (error) { + logTerminal('error', 'Failed to fetch authorized terminal hosts.', { + ...connectionContext, + error: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); + ws.close(1011, 'Failed to fetch terminal authorization data'); + return; + } + userSessions.set(userId, userSession); + logTerminal('log', 'Terminal websocket connection established.', { + ...connectionContext, + authorizedHostCount: userSession.authorizedIPs.length, + }); ws.on('message', (message) => { handleMessage(userSession, message); - }); ws.on('error', (err) => handleError(err, userId)); - ws.on('close', () => handleClose(userId)); - + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); }); const messageHandlers = { @@ -98,6 +176,7 @@ const messageHandlers = { }, pause: (session) => session.ptyProcess.pause(), resume: (session) => session.ptyProcess.resume(), + ping: (session) => session.ws.send('pong'), checkActive: (session, data) => { if (data === 'force' && session.isActive) { killPtyProcess(session.userId); @@ -110,12 +189,34 @@ const messageHandlers = { function handleMessage(userSession, message) { const parsed = parseMessage(message); - if (!parsed) return; + if (!parsed) { + logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', { + userId: userSession.userId, + rawMessage: String(message).slice(0, 500), + }); + return; + } + + logTerminal('log', 'Received websocket message.', { + userId: userSession.userId, + keys: Object.keys(parsed), + isActive: userSession.isActive, + }); Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; - if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) { + if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { handler(userSession, value); + } else if (!handler) { + logTerminal('warn', 'Ignoring websocket message with unknown handler key.', { + userId: userSession.userId, + key, + }); + } else { + logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', { + userId: userSession.userId, + key, + }); } }); } @@ -124,7 +225,9 @@ function parseMessage(message) { try { return JSON.parse(message); } catch (e) { - console.error('Failed to parse message:', e); + logTerminal('error', 'Failed to parse websocket message.', { + error: e?.message ?? e, + }); return null; } } @@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) { if (userSession && userSession.isActive) { const result = await killPtyProcess(userId); if (!result) { + logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', { + userId, + }); // if terminal is still active, even after we tried to kill it, dont continue and show error ws.send('unprocessable'); return; @@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) { // Extract target host from SSH command const targetHost = extractTargetHost(sshArgs); + logTerminal('log', 'Parsed terminal command metadata.', { + userId, + targetHost, + timeout, + sshArgs, + authorizedIPs: userSession?.authorizedIPs ?? [], + }); + if (!targetHost) { + logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', { + userId, + sshArgs, + }); ws.send('Invalid SSH command: No target host found'); return; } // Validate target host against authorized IPs - if (!userSession.authorizedIPs.includes(targetHost)) { + if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) { + logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', { + userId, + targetHost, + authorizedIPs: userSession.authorizedIPs, + }); ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`); return; } @@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) { // NOTE: - Initiates a process within the Terminal container // Establishes an SSH connection to root@coolify with RequestTTY enabled // Executes the 'docker exec' command to connect to a specific container + logTerminal('log', 'Spawning PTY process for terminal session.', { + userId, + targetHost, + timeout, + }); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; @@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) { // when parent closes ptyProcess.onExit(({ exitCode, signal }) => { - console.error(`Process exited with code ${exitCode} and signal ${signal}`); + logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', { + userId, + exitCode, + signal, + }); ws.send('pty-exited'); userSession.isActive = false; }); @@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) { } } -function extractTargetHost(sshArgs) { - // Find the argument that matches the pattern user@host - const userAtHost = sshArgs.find(arg => { - // Skip paths that contain 'storage/app/ssh/keys/' - if (arg.includes('storage/app/ssh/keys/')) { - return false; - } - return /^[^@]+@[^@]+$/.test(arg); - }); - if (!userAtHost) return null; - - // Extract host from user@host - const host = userAtHost.split('@')[1]; - return host; -} - async function handleError(err, userId) { - console.error('WebSocket error:', err); + logTerminal('error', 'WebSocket error.', { + userId, + error: err?.message ?? err, + }); await killPtyProcess(userId); } async function handleClose(userId) { + logTerminal('log', 'Cleaning up terminal websocket session.', { + userId, + }); await killPtyProcess(userId); userSessions.delete(userId); } @@ -231,6 +353,11 @@ async function killPtyProcess(userId) { const attemptKill = () => { killAttempts++; + logTerminal('log', 'Attempting to terminate PTY process.', { + userId, + killAttempts, + maxAttempts, + }); // session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098 // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947 @@ -238,6 +365,10 @@ async function killPtyProcess(userId) { setTimeout(() => { if (!session.isActive || !session.ptyProcess) { + logTerminal('log', 'PTY process terminated successfully.', { + userId, + killAttempts, + }); resolve(true); return; } @@ -245,6 +376,10 @@ async function killPtyProcess(userId) { if (killAttempts < maxAttempts) { attemptKill(); } else { + logTerminal('warn', 'PTY process still active after maximum termination attempts.', { + userId, + killAttempts, + }); resolve(false); } }, 500); @@ -258,76 +393,8 @@ function generateUserId() { return Math.random().toString(36).substring(2, 11); } -function extractTimeout(commandString) { - const timeoutMatch = commandString.match(/timeout (\d+)/); - return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; -} - -function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); - if (!sshCommandMatch) return []; - - const argsString = sshCommandMatch[1]; - let sshArgs = []; - - // Parse shell arguments respecting quotes - let current = ''; - let inQuotes = false; - let quoteChar = ''; - let i = 0; - - while (i < argsString.length) { - const char = argsString[i]; - const nextChar = argsString[i + 1]; - - if (!inQuotes && (char === '"' || char === "'")) { - // Starting a quoted section - inQuotes = true; - quoteChar = char; - current += char; - } else if (inQuotes && char === quoteChar) { - // Ending a quoted section - inQuotes = false; - current += char; - quoteChar = ''; - } else if (!inQuotes && char === ' ') { - // Space outside quotes - end of argument - if (current.trim()) { - sshArgs.push(current.trim()); - current = ''; - } - } else { - // Regular character - current += char; - } - i++; - } - - // Add final argument if exists - if (current.trim()) { - sshArgs.push(current.trim()); - } - - // Replace RequestTTY=no with RequestTTY=yes - sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); - - // Add RequestTTY=yes if not present - if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { - sshArgs.push('-o', 'RequestTTY=yes'); - } - - return sshArgs; -} - -function extractHereDocContent(commandString) { - const delimiterMatch = commandString.match(/<< (\S+)/); - const delimiter = delimiterMatch ? delimiterMatch[1] : null; - const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); - const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); - const hereDocMatch = commandString.match(hereDocRegex); - return hereDocMatch ? hereDocMatch[1] : ''; -} - server.listen(6002, () => { - console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!'); + logTerminal('log', 'Terminal debug logging is enabled.', { + terminalDebugEnabled, + }); }); diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js new file mode 100644 index 000000000..7456b282c --- /dev/null +++ b/docker/coolify-realtime/terminal-utils.js @@ -0,0 +1,127 @@ +export function extractTimeout(commandString) { + const timeoutMatch = commandString.match(/timeout (\d+)/); + return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; +} + +function normalizeShellArgument(argument) { + if (!argument) { + return argument; + } + + return argument + .replace(/'([^']*)'/g, '$1') + .replace(/"([^"]*)"/g, '$1'); +} + +export function extractSshArgs(commandString) { + const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + if (!sshCommandMatch) return []; + + const argsString = sshCommandMatch[1]; + let sshArgs = []; + + let current = ''; + let inQuotes = false; + let quoteChar = ''; + let i = 0; + + while (i < argsString.length) { + const char = argsString[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + current += char; + quoteChar = ''; + } else if (!inQuotes && char === ' ') { + if (current.trim()) { + sshArgs.push(current.trim()); + current = ''; + } + } else { + current += char; + } + i++; + } + + if (current.trim()) { + sshArgs.push(current.trim()); + } + + sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg)); + sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); + + if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { + sshArgs.push('-o', 'RequestTTY=yes'); + } + + return sshArgs; +} + +export function extractHereDocContent(commandString) { + const delimiterMatch = commandString.match(/<< (\S+)/); + const delimiter = delimiterMatch ? delimiterMatch[1] : null; + const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); + + if (!escapedDelimiter) { + return ''; + } + + const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); + const hereDocMatch = commandString.match(hereDocRegex); + return hereDocMatch ? hereDocMatch[1] : ''; +} + +export function normalizeHostForAuthorization(host) { + if (!host) { + return null; + } + + let normalizedHost = host.trim(); + + while ( + normalizedHost.length >= 2 && + ((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) || + (normalizedHost.startsWith('"') && normalizedHost.endsWith('"'))) + ) { + normalizedHost = normalizedHost.slice(1, -1).trim(); + } + + if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) { + normalizedHost = normalizedHost.slice(1, -1); + } + + return normalizedHost.toLowerCase(); +} + +export function extractTargetHost(sshArgs) { + const userAtHost = sshArgs.find(arg => { + if (arg.includes('storage/app/ssh/keys/')) { + return false; + } + + return /^[^@]+@[^@]+$/.test(arg); + }); + + if (!userAtHost) { + return null; + } + + const atIndex = userAtHost.indexOf('@'); + return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1)); +} + +export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { + const normalizedTargetHost = normalizeHostForAuthorization(targetHost); + + if (!normalizedTargetHost) { + return false; + } + + return authorizedHosts + .map(host => normalizeHostForAuthorization(host)) + .includes(normalizedTargetHost); +} diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js new file mode 100644 index 000000000..3da444155 --- /dev/null +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractSshArgs, + extractTargetHost, + isAuthorizedTargetHost, + normalizeHostForAuthorization, +} from './terminal-utils.js'; + +test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + +test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']); +}); + +test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h'); + assert.equal(sshArgs[4], 'root@example.com'); +}); + +test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { + assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); + assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); +}); + +test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { + assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10'); + assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true); +}); + +test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { + assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); +}); diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index ab9cb2fca..77013e1b9 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -33,7 +33,8 @@ RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \ docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx # Install PostgreSQL repository and keys -RUN apk add --no-cache gnupg && \ +RUN apk upgrade --no-cache && \ + apk add --no-cache gnupg && \ mkdir -p /usr/share/keyrings && \ curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg @@ -47,6 +48,9 @@ RUN apk add --no-cache \ lsof \ vim +# Install PHP extensions +RUN install-php-extensions sockets + # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ echo "alias a='php artisan'" >> /etc/profile && \ diff --git a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run index ada19b3a3..dbc472d06 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run @@ -1,12 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:horizon -} +if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then + echo " INFO Horizon is disabled, sleeping." + exec sleep infinity +fi +echo " INFO Horizon is enabled, starting..." +exec php artisan horizon diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup @@ -0,0 +1 @@ + diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run new file mode 100644 index 000000000..ee46dba7e --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -0,0 +1,11 @@ +#!/bin/sh + +cd /var/www/html + +if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then + echo " INFO Nightwatch is enabled, starting..." + exec php artisan nightwatch:agent +fi + +echo " INFO Nightwatch is disabled, sleeping." +exec sleep infinity diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type new file mode 100644 index 000000000..5883cff0c --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type @@ -0,0 +1 @@ +longrun diff --git a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run index b81a44833..bfa44c7e3 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run @@ -1,13 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:scheduler -} - +if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then + echo " INFO Scheduler is disabled, sleeping." + exec sleep infinity +fi +echo " INFO Scheduler is enabled, starting..." +exec php artisan schedule:work diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent @@ -0,0 +1 @@ + diff --git a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run index be6647607..dbc472d06 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run @@ -1,11 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:horizon -} +if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then + echo " INFO Horizon is disabled, sleeping." + exec sleep infinity +fi + +echo " INFO Horizon is enabled, starting..." +exec php artisan horizon diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script @@ -0,0 +1 @@ + diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run new file mode 100644 index 000000000..ee46dba7e --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -0,0 +1,11 @@ +#!/bin/sh + +cd /var/www/html + +if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then + echo " INFO Nightwatch is enabled, starting..." + exec php artisan nightwatch:agent +fi + +echo " INFO Nightwatch is disabled, sleeping." +exec sleep infinity diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type new file mode 100644 index 000000000..5883cff0c --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type @@ -0,0 +1 @@ +longrun diff --git a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run index a2ecb0a73..bfa44c7e3 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run @@ -1,10 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:scheduler -} + +if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then + echo " INFO Scheduler is disabled, sleeping." + exec sleep infinity +fi + +echo " INFO Scheduler is enabled, starting..." +exec php artisan schedule:work diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent b/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent @@ -0,0 +1 @@ + diff --git a/jean.json b/jean.json index 402bcd02d..5cd8362d9 100644 --- a/jean.json +++ b/jean.json @@ -1,6 +1,13 @@ { "scripts": { "setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json", + "teardown": null, "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down" - } -} \ No newline at end of file + }, + "ports": [ + { + "port": 8000, + "label": "Coolify UI" + } + ] +} diff --git a/openapi.json b/openapi.json index bd502865a..239068300 100644 --- a/openapi.json +++ b/openapi.json @@ -407,6 +407,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." } }, "type": "object" @@ -852,6 +857,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." } }, "type": "object" @@ -1297,6 +1307,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." } }, "type": "object" @@ -2704,6 +2719,10 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false." } }, "type": "object" @@ -3063,13 +3082,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -3345,6 +3358,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -3439,6 +3461,333 @@ ] } }, + "\/applications\/{uuid}\/storages": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by application UUID.", + "operationId": "list-storages-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by application UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for an application.", + "operationId": "create-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by application UUID.", + "operationId": "update-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by application UUID.", + "operationId": "delete-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -3982,6 +4331,11 @@ "database_backup_retention_max_storage_s3": { "type": "integer", "description": "Max storage (MB) for S3 backups" + }, + "timeout": { + "type": "integer", + "description": "Backup job timeout in seconds (min: 60, max: 36000)", + "default": 3600 } }, "type": "object" @@ -4214,6 +4568,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -4543,6 +4901,11 @@ "database_backup_retention_max_storage_s3": { "type": "integer", "description": "Max storage of the backup in S3" + }, + "timeout": { + "type": "integer", + "description": "Backup job timeout in seconds (min: 60, max: 36000)", + "default": 3600 } }, "type": "object" @@ -4659,6 +5022,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -4787,6 +5154,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -4911,6 +5282,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5039,6 +5414,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5167,6 +5546,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5307,6 +5690,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5447,6 +5834,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5575,6 +5966,10 @@ "type": "integer", "description": "Public port of the database" }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, "limits_memory": { "type": "string", "description": "Memory limit of the database" @@ -5870,6 +6265,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5959,6 +6363,714 @@ ] } }, + "\/databases\/{uuid}\/envs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Envs", + "description": "List all envs by database UUID.", + "operationId": "list-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variables.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Env", + "description": "Create env by database UUID.", + "operationId": "create-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "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" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "nc0k04gk8g0cgsk440g0koko" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Env", + "description": "Update env by database UUID.", + "operationId": "update-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "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" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/bulk": { + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Envs (Bulk)", + "description": "Update multiple envs by database UUID.", + "operationId": "update-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Bulk envs updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "data" + ], + "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_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": { + "description": "Environment variables updated.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/{env_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Env", + "description": "Delete env by UUID.", + "operationId": "delete-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_uuid", + "in": "path", + "description": "UUID of the environment variable.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment variable deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by database UUID.", + "operationId": "list-storages-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by database UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a database.", + "operationId": "create-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by database UUID.", + "operationId": "update-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by database UUID.", + "operationId": "delete-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -6172,6 +7284,22 @@ "schema": { "type": "integer" } + }, + { + "name": "pull_request_id", + "in": "query", + "description": "Preview deployment identifier. Alias of pr.", + "schema": { + "type": "integer" + } + }, + { + "name": "docker_tag", + "in": "query", + "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.", + "schema": { + "type": "string" + } } ], "responses": { @@ -8055,6 +9183,698 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on an application.", + "operationId": "list-scheduled-task-executions-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on a service.", + "operationId": "list-scheduled-task-executions-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/security\/keys": { "get": { "tags": [ @@ -8641,6 +10461,26 @@ "none" ], "description": "The proxy type." + }, + "concurrent_builds": { + "type": "integer", + "description": "Number of concurrent builds." + }, + "dynamic_timeout": { + "type": "integer", + "description": "Deployment timeout in seconds." + }, + "deployment_queue_limit": { + "type": "integer", + "description": "Maximum number of queued deployments." + }, + "server_disk_usage_notification_threshold": { + "type": "integer", + "description": "Server disk usage notification threshold (%)." + }, + "server_disk_usage_check_frequency": { + "type": "string", + "description": "Cron expression for disk usage check frequency." } }, "type": "object" @@ -8981,6 +10821,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" @@ -9307,6 +11152,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" @@ -9617,13 +11467,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -9721,13 +11565,10 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variables updated." - } - }, - "type": "object" + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } } } } @@ -9884,6 +11725,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -9982,6 +11832,338 @@ ] } }, + "\/services\/{uuid}\/storages": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by service UUID.", + "operationId": "list-storages-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by service UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a service sub-resource.", + "operationId": "create-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path", + "resource_uuid" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "resource_uuid": { + "type": "string", + "description": "UUID of the service application or database sub-resource." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Services" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by service UUID.", + "operationId": "update-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by service UUID.", + "operationId": "delete-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -10347,6 +12529,19 @@ "type": "integer", "description": "Health check start period in seconds." }, + "health_check_type": { + "type": "string", + "description": "Health check type: http or cmd.", + "enum": [ + "http", + "cmd" + ] + }, + "health_check_command": { + "type": "string", + "nullable": true, + "description": "Health check command for CMD type." + }, "limits_memory": { "type": "string", "description": "Memory limit." @@ -10585,6 +12780,10 @@ "pull_request_id": { "type": "integer" }, + "docker_registry_image_tag": { + "type": "string", + "nullable": true + }, "force_rebuild": { "type": "boolean" }, @@ -10714,6 +12913,10 @@ "real_value": { "type": "string" }, + "comment": { + "type": "string", + "nullable": true + }, "version": { "type": "string" }, @@ -10786,6 +12989,110 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, + "ScheduledTaskExecution": { + "description": "Scheduled Task Execution model", + "properties": { + "uuid": { + "type": "string", + "description": "The unique identifier of the execution." + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed", + "running" + ], + "description": "The status of the execution." + }, + "message": { + "type": "string", + "nullable": true, + "description": "The output message of the execution." + }, + "retry_count": { + "type": "integer", + "description": "The number of retries." + }, + "duration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution finished." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last updated." + } + }, + "type": "object" + }, "Server": { "description": "Server model", "properties": { @@ -11298,6 +13605,10 @@ "name": "Resources", "description": "Resources" }, + { + "name": "Scheduled Tasks", + "description": "Scheduled Tasks" + }, { "name": "Private Keys", "description": "Private Keys" @@ -11315,4 +13626,4 @@ "description": "Teams" } ] -} \ No newline at end of file +} diff --git a/openapi.yaml b/openapi.yaml index 11148f43b..5bf6059af 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -291,6 +291,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' type: object responses: '201': @@ -575,6 +579,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' type: object responses: '201': @@ -859,6 +867,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' type: object responses: '201': @@ -1741,6 +1753,9 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + is_preserve_repository_enabled: + type: boolean + description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.' type: object responses: '200': @@ -1952,9 +1967,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -2113,6 +2126,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop application.' @@ -2165,6 +2185,219 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages': + get: + tags: + - Applications + summary: 'List Storages' + description: 'List all persistent storages and file storages by application UUID.' + operationId: list-storages-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by application UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for an application.' + operationId: create-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + properties: + type: + type: string + enum: [persistent, file] + description: 'The type of storage.' + name: + type: string + description: 'Volume name (persistent only, required for persistent).' + mount_path: + type: string + description: 'The container mount path.' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, optional).' + content: + type: string + nullable: true + description: 'File content (file only, optional).' + is_directory: + type: boolean + description: 'Whether this is a directory mount (file only, default false).' + fs_path: + type: string + description: 'Host directory path (required when is_directory is true).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Applications + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by application UUID.' + operationId: update-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' + required: true + content: + application/json: + schema: + required: + - type + properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' + id: + type: integer + description: 'The ID of the storage (deprecated, use uuid instead).' + type: + type: string + enum: [persistent, file] + description: 'The type of storage: persistent or file.' + is_preview_suffix_enabled: + type: boolean + description: 'Whether to add -pr-N suffix for preview deployments.' + name: + type: string + description: 'The volume name (persistent only, not allowed for read-only storages).' + mount_path: + type: string + description: 'The container mount path (not allowed for read-only storages).' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, not allowed for read-only storages).' + content: + type: string + nullable: true + description: 'The file content (file only, not allowed for read-only storages).' + type: object + additionalProperties: false + responses: + '200': + description: 'Storage updated.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Applications + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by application UUID.' + operationId: delete-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -2501,6 +2734,10 @@ paths: database_backup_retention_max_storage_s3: type: integer description: 'Max storage (MB) for S3 backups' + timeout: + type: integer + description: 'Backup job timeout in seconds (min: 60, max: 36000)' + default: 3600 type: object responses: '201': @@ -2655,6 +2892,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -2889,6 +3129,10 @@ paths: database_backup_retention_max_storage_s3: type: integer description: 'Max storage of the backup in S3' + timeout: + type: integer + description: 'Backup job timeout in seconds (min: 60, max: 36000)' + default: 3600 type: object responses: '200': @@ -2971,6 +3215,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3063,6 +3310,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3152,6 +3402,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3244,6 +3497,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3336,6 +3592,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3437,6 +3696,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3538,6 +3800,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3630,6 +3895,9 @@ paths: public_port: type: integer description: 'Public port of the database' + public_port_timeout: + type: integer + description: 'Public port timeout in seconds (default: 3600)' limits_memory: type: string description: 'Memory limit of the database' @@ -3808,6 +4076,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop database.' @@ -3859,6 +4134,455 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/envs': + get: + tags: + - Databases + summary: 'List Envs' + description: 'List all envs by database UUID.' + operationId: list-envs-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'Environment variables.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Env' + description: 'Create env by database UUID.' + operationId: create-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Env created.' + required: true + content: + application/json: + schema: + properties: + key: + type: string + description: 'The key of the environment variable.' + value: + type: string + description: 'The value of the environment variable.' + 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 + responses: + '201': + description: 'Environment variable created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: nc0k04gk8g0cgsk440g0koko } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: 'Update Env' + description: 'Update env by database UUID.' + operationId: update-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Env updated.' + required: true + content: + application/json: + schema: + required: + - key + - value + properties: + key: + type: string + description: 'The key of the environment variable.' + value: + type: string + description: 'The value of the environment variable.' + 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 + responses: + '201': + description: 'Environment variable updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/envs/bulk': + patch: + tags: + - Databases + summary: 'Update Envs (Bulk)' + description: 'Update multiple envs by database UUID.' + operationId: update-envs-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Bulk envs updated.' + required: true + content: + application/json: + schema: + required: + - data + 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_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': + description: 'Environment variables updated.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/envs/{env_uuid}': + delete: + tags: + - Databases + summary: 'Delete Env' + description: 'Delete env by UUID.' + operationId: delete-env-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: env_uuid + in: path + description: 'UUID of the environment variable.' + required: true + schema: + type: string + responses: + '200': + description: 'Environment variable deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Environment variable deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/databases/{uuid}/storages': + get: + tags: + - Databases + summary: 'List Storages' + description: 'List all persistent storages and file storages by database UUID.' + operationId: list-storages-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by database UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for a database.' + operationId: create-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + properties: + type: + type: string + enum: [persistent, file] + description: 'The type of storage.' + name: + type: string + description: 'Volume name (persistent only, required for persistent).' + mount_path: + type: string + description: 'The container mount path.' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, optional).' + content: + type: string + nullable: true + description: 'File content (file only, optional).' + is_directory: + type: boolean + description: 'Whether this is a directory mount (file only, default false).' + fs_path: + type: string + description: 'Host directory path (required when is_directory is true).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by database UUID.' + operationId: update-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' + required: true + content: + application/json: + schema: + required: + - type + properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' + id: + type: integer + description: 'The ID of the storage (deprecated, use uuid instead).' + type: + type: string + enum: [persistent, file] + description: 'The type of storage: persistent or file.' + is_preview_suffix_enabled: + type: boolean + description: 'Whether to add -pr-N suffix for preview deployments.' + name: + type: string + description: 'The volume name (persistent only, not allowed for read-only storages).' + mount_path: + type: string + description: 'The container mount path (not allowed for read-only storages).' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, not allowed for read-only storages).' + content: + type: string + nullable: true + description: 'The file content (file only, not allowed for read-only storages).' + type: object + additionalProperties: false + responses: + '200': + description: 'Storage updated.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Databases + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by database UUID.' + operationId: delete-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -3994,6 +4718,18 @@ paths: description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.' schema: type: integer + - + name: pull_request_id + in: query + description: 'Preview deployment identifier. Alias of pr.' + schema: + type: integer + - + name: docker_tag + in: query + description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.' + schema: + type: string responses: '200': description: "Get deployment(s) UUID's" @@ -5087,6 +5823,478 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on an application.' + operationId: list-scheduled-task-executions-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on a service.' + operationId: list-scheduled-task-executions-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /security/keys: get: tags: @@ -5469,6 +6677,21 @@ paths: type: string enum: [traefik, caddy, none] description: 'The proxy type.' + concurrent_builds: + type: integer + description: 'Number of concurrent builds.' + dynamic_timeout: + type: integer + description: 'Deployment timeout in seconds.' + deployment_queue_limit: + type: integer + description: 'Maximum number of queued deployments.' + server_disk_usage_notification_threshold: + type: integer + description: 'Server disk usage notification threshold (%).' + server_disk_usage_check_frequency: + type: string + description: 'Cron expression for disk usage check frequency.' type: object responses: '201': @@ -5668,6 +6891,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '201': @@ -5853,6 +7080,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '200': @@ -6027,9 +7258,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6075,9 +7304,9 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variables updated.' } - type: object + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6177,6 +7406,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop service.' @@ -6235,6 +7471,223 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/storages': + get: + tags: + - Services + summary: 'List Storages' + description: 'List all persistent storages and file storages by service UUID.' + operationId: list-storages-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by service UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Services + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for a service sub-resource.' + operationId: create-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + - resource_uuid + properties: + type: + type: string + enum: [persistent, file] + description: 'The type of storage.' + resource_uuid: + type: string + description: 'UUID of the service application or database sub-resource.' + name: + type: string + description: 'Volume name (persistent only, required for persistent).' + mount_path: + type: string + description: 'The container mount path.' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, optional).' + content: + type: string + nullable: true + description: 'File content (file only, optional).' + is_directory: + type: boolean + description: 'Whether this is a directory mount (file only, default false).' + fs_path: + type: string + description: 'Host directory path (required when is_directory is true).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Services + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by service UUID.' + operationId: update-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' + required: true + content: + application/json: + schema: + required: + - type + properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' + id: + type: integer + description: 'The ID of the storage (deprecated, use uuid instead).' + type: + type: string + enum: [persistent, file] + description: 'The type of storage: persistent or file.' + is_preview_suffix_enabled: + type: boolean + description: 'Whether to add -pr-N suffix for preview deployments.' + name: + type: string + description: 'The volume name (persistent only, not allowed for read-only storages).' + mount_path: + type: string + description: 'The container mount path (not allowed for read-only storages).' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, not allowed for read-only storages).' + content: + type: string + nullable: true + description: 'The file content (file only, not allowed for read-only storages).' + type: object + additionalProperties: false + responses: + '200': + description: 'Storage updated.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Services + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by service UUID.' + operationId: delete-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /teams: get: tags: @@ -6492,6 +7945,16 @@ components: health_check_start_period: type: integer description: 'Health check start period in seconds.' + health_check_type: + type: string + description: 'Health check type: http or cmd.' + enum: + - http + - cmd + health_check_command: + type: string + nullable: true + description: 'Health check command for CMD type.' limits_memory: type: string description: 'Memory limit.' @@ -6677,6 +8140,9 @@ components: type: string pull_request_id: type: integer + docker_registry_image_tag: + type: string + nullable: true force_rebuild: type: boolean commit: @@ -6763,6 +8229,9 @@ components: type: string real_value: type: string + comment: + type: string + nullable: true version: type: string created_at: @@ -6811,6 +8280,86 @@ components: description: type: string type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object + ScheduledTaskExecution: + description: 'Scheduled Task Execution model' + properties: + uuid: + type: string + description: 'The unique identifier of the execution.' + status: + type: string + enum: + - success + - failed + - running + description: 'The status of the execution.' + message: + type: string + nullable: true + description: 'The output message of the execution.' + retry_count: + type: integer + description: 'The number of retries.' + duration: + type: number + nullable: true + description: 'Duration in seconds.' + started_at: + type: string + format: date-time + nullable: true + description: 'When the execution started.' + finished_at: + type: string + format: date-time + nullable: true + description: 'When the execution finished.' + created_at: + type: string + format: date-time + description: 'When the record was created.' + updated_at: + type: string + format: date-time + description: 'When the record was last updated.' + type: object Server: description: 'Server model' properties: @@ -7165,6 +8714,9 @@ tags: - name: Resources description: Resources + - + name: 'Scheduled Tasks' + description: 'Scheduled Tasks' - name: 'Private Keys' description: 'Private Keys' diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 46e0e88e5..901aeb833 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" @@ -72,6 +72,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 6306ab381..998d35974 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -79,7 +79,7 @@ services: retries: 10 timeout: 2s redis: - image: redis:alpine + image: redis:7-alpine pull_policy: always container_name: coolify-redis restart: always @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' pull_policy: always container_name: coolify-realtime restart: always @@ -113,6 +113,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/other/nightly/docker-compose.yml b/other/nightly/docker-compose.yml index 68d0f0744..0fd3dda07 100644 --- a/other/nightly/docker-compose.yml +++ b/other/nightly/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: always working_dir: /var/www/html extra_hosts: - - 'host.docker.internal:host-gateway' + - host.docker.internal:host-gateway networks: - coolify depends_on: @@ -18,7 +18,7 @@ services: networks: - coolify redis: - image: redis:alpine + image: redis:7-alpine container_name: coolify-redis restart: always networks: @@ -26,7 +26,7 @@ services: soketi: container_name: coolify-realtime extra_hosts: - - 'host.docker.internal:host-gateway' + - host.docker.internal:host-gateway restart: always networks: - coolify diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 921ba6a92..4a91f7b1b 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -DOCKER_VERSION="27.0" +DOCKER_VERSION="latest" # TODO: Ask for a user CURRENT_USER=$USER @@ -499,13 +499,10 @@ fi install_docker() { set +e - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true + curl -fsSL https://get.docker.com | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo "Automated Docker installation failed. Trying manual installation." - install_docker_manually - fi + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi set -e } @@ -542,22 +539,21 @@ install_docker_manually() { echo "Docker installed successfully." fi } + +install_docker_from_rhel_repo() { + echo " - Installing Docker from the RHEL repository for Rocky Linux..." + rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo + dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo + dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl --now enable docker +} + log_section "Step 3/9: Checking Docker installation" echo "3/9 Checking Docker installation..." if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1 @@ -569,8 +565,9 @@ if ! [ -x "$(command -v docker)" ]; then fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1 systemctl enable docker.service >/dev/null 2>&1 + systemctl start docker.service >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Failed to install Docker with pacman. Try to install it manually." echo " Please visit https://wiki.archlinux.org/title/docker for more information." @@ -581,7 +578,7 @@ if ! [ -x "$(command -v docker)" ]; then dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 @@ -591,34 +588,35 @@ if ! [ -x "$(command -v docker)" ]; then exit 1 fi ;; - "centos" | "fedora" | "rhel" | "tencentos") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 - fi - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + "rocky") + install_docker_from_rhel_repo if ! [ -x "$(command -v docker)" ]; then echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi + ;; + "almalinux" | "tencentos") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi ;; - "ubuntu" | "debian" | "raspbian") + "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles") install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; *) install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; esac @@ -627,6 +625,19 @@ else echo " - Docker is installed." fi +# Verify minimum Docker version +MIN_DOCKER_VERSION=24 +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +if [ -z "$INSTALLED_DOCKER_VERSION" ]; then + echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." +elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then + echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." + echo " Please upgrade Docker: https://docs.docker.com/engine/install/" + exit 1 +else + echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." +fi + log_section "Step 4/9: Checking Docker configuration" echo "4/9 Checking Docker configuration..." diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 1ce790111..7012f481e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,29 +1,29 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.473" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0" }, "helper": { - "version": "1.0.12" + "version": "1.0.13" }, "realtime": { - "version": "1.0.10" + "version": "1.0.13" }, "sentinel": { - "version": "0.0.18" + "version": "0.0.21" } }, "traefik": { - "v3.6": "3.6.5", + "v3.6": "3.6.11", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.32" + "v2.11": "2.11.40" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 6244197fb..1fcd7cc1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,19 +10,20 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", "@vitejs/plugin-vue": "6.0.3", - "axios": "1.13.2", + "axios": "1.15.0", "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", "postcss": "8.5.6", "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.0", + "vite": "7.3.2", "vue": "3.5.26" } }, @@ -595,9 +596,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -609,9 +610,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -623,9 +624,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -637,9 +638,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -651,9 +652,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -665,9 +666,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -679,9 +680,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -693,9 +694,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -707,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -721,9 +722,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -735,9 +736,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -749,9 +750,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -763,9 +764,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -777,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -791,9 +792,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -805,9 +806,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -819,9 +820,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -833,9 +834,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -847,9 +848,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -861,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -875,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -889,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -903,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -917,9 +918,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -931,9 +932,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -949,7 +950,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1186,6 +1188,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "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.1.0", + "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": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "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.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -1402,8 +1464,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1413,15 +1474,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/call-bind-apply-helpers": { @@ -1556,6 +1617,7 @@ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -1570,6 +1632,7 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -2325,12 +2388,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2338,6 +2400,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2395,11 +2501,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pusher-js": { "version": "8.4.0", @@ -2407,7 +2516,6 @@ "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -2445,9 +2553,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2461,31 +2569,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -2512,6 +2620,7 @@ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -2556,8 +2665,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2604,12 +2712,11 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2691,9 +2798,9 @@ } }, "node_modules/vite-plugin-full-reload/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2709,7 +2816,6 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -2732,6 +2838,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2753,6 +2860,7 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/package.json b/package.json index dc4b912ea..3afefa833 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,14 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "@vitejs/plugin-vue": "6.0.3", - "axios": "1.13.2", + "axios": "1.15.0", "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", "postcss": "8.5.6", "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.0", + "vite": "7.3.2", "vue": "3.5.26" }, "dependencies": { @@ -24,6 +24,7 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" } -} \ No newline at end of file +} diff --git a/phpunit.xml b/phpunit.xml index 38adfdb6f..5d55acf75 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,18 +7,22 @@ ./tests/Feature + + ./tests/v4 + - - - - - - + + + + + + + diff --git a/public/svgs/electricsql.svg b/public/svgs/electricsql.svg new file mode 100644 index 000000000..bbffe200a --- /dev/null +++ b/public/svgs/electricsql.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/svgs/espocrm.svg b/public/svgs/espocrm.svg new file mode 100644 index 000000000..79d96f8c3 --- /dev/null +++ b/public/svgs/espocrm.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/grimmory.svg b/public/svgs/grimmory.svg new file mode 100644 index 000000000..cd8230fa2 --- /dev/null +++ b/public/svgs/grimmory.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/svgs/imgcompress.png b/public/svgs/imgcompress.png new file mode 100644 index 000000000..9eb04c3a7 Binary files /dev/null and b/public/svgs/imgcompress.png differ diff --git a/public/svgs/librespeed.png b/public/svgs/librespeed.png new file mode 100644 index 000000000..1405e3c18 Binary files /dev/null and b/public/svgs/librespeed.png differ diff --git a/public/svgs/spacebot.png b/public/svgs/spacebot.png new file mode 100644 index 000000000..8ec1da702 Binary files /dev/null and b/public/svgs/spacebot.png differ diff --git a/public/svgs/sure.png b/public/svgs/sure.png new file mode 100644 index 000000000..7a0bb69c1 Binary files /dev/null and b/public/svgs/sure.png differ diff --git a/resources/css/app.css b/resources/css/app.css index eeba1ee01..936e0c713 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -14,7 +14,10 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: Inter, sans-serif; + --font-sans: 'Geist Sans', Inter, sans-serif; + --font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + --font-geist-sans: 'Geist Sans', Inter, sans-serif; + --font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; --color-base: #101010; --color-warning: #fcd452; @@ -96,7 +99,7 @@ body { } body { - @apply min-h-screen text-sm antialiased scrollbar overflow-x-hidden; + @apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden; } .coolify-monaco-editor { @@ -163,7 +166,7 @@ tbody { } tr { - @apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200; + @apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100; } tr th { diff --git a/resources/css/fonts.css b/resources/css/fonts.css index c8c4448eb..e5c6a694d 100644 --- a/resources/css/fonts.css +++ b/resources/css/fonts.css @@ -70,3 +70,18 @@ @font-face { src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2'); } +@font-face { + font-display: swap; + font-family: 'Geist Mono'; + font-style: normal; + font-weight: 100 900; + src: url('../fonts/geist-mono-variable.woff2') format('woff2'); +} + +@font-face { + font-display: swap; + font-family: 'Geist Sans'; + font-style: normal; + font-weight: 100 900; + src: url('../fonts/geist-sans-variable.woff2') format('woff2'); +} diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 02be0c0c4..a8e807041 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -145,6 +145,10 @@ @utility dropdown-item { @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs; } +@utility dropdown-item-touch { + @apply min-h-10 px-3 py-2 text-sm; +} + @utility dropdown-item-no-padding { @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs; } diff --git a/resources/fonts/geist-mono-variable.woff2 b/resources/fonts/geist-mono-variable.woff2 new file mode 100644 index 000000000..c8a7d8401 Binary files /dev/null and b/resources/fonts/geist-mono-variable.woff2 differ diff --git a/resources/fonts/geist-sans-variable.woff2 b/resources/fonts/geist-sans-variable.woff2 new file mode 100644 index 000000000..7ebce69dc Binary files /dev/null and b/resources/fonts/geist-sans-variable.woff2 differ diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 6707bec98..aa5f37353 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { FitAddon } from '@xterm/addon-fit'; +const terminalDebugEnabled = import.meta.env.DEV; + +function logTerminal(level, message, ...context) { + if (!terminalDebugEnabled) { + return; + } + + console[level](message, ...context); +} + export function initializeTerminalComponent() { function terminalData() { return { @@ -30,6 +40,8 @@ export function initializeTerminalComponent() { pingTimeoutId: null, heartbeatMissed: 0, maxHeartbeatMisses: 3, + // Command buffering for race condition prevention + pendingCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -120,6 +132,7 @@ export function initializeTerminalComponent() { this.checkIfProcessIsRunningAndKillIt(); this.clearAllTimers(); this.connectionState = 'disconnected'; + this.pendingCommand = null; if (this.socket) { this.socket.close(1000, 'Client cleanup'); } @@ -154,6 +167,7 @@ export function initializeTerminalComponent() { this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; + this.pendingCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); @@ -172,7 +186,7 @@ export function initializeTerminalComponent() { this.term = new Terminal({ cols: 80, rows: 30, - fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', + fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"', cursorBlink: true, rendererType: 'canvas', convertEol: true, @@ -188,7 +202,7 @@ export function initializeTerminalComponent() { initializeWebSocket() { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { - console.log('[Terminal] WebSocket already connecting/connected, skipping'); + logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping'); return; // Already connecting or connected } @@ -197,7 +211,7 @@ export function initializeTerminalComponent() { // Ensure terminal config is available if (!window.terminalConfig) { - console.warn('[Terminal] Terminal config not available, using defaults'); + logTerminal('warn', '[Terminal] Terminal config not available, using defaults'); window.terminalConfig = {}; } @@ -223,7 +237,7 @@ export function initializeTerminalComponent() { } const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` - console.log(`[Terminal] Attempting connection to: ${url}`); + logTerminal('log', `[Terminal] Attempting connection to: ${url}`); try { this.socket = new WebSocket(url); @@ -232,7 +246,7 @@ export function initializeTerminalComponent() { const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout; this.connectionTimeoutId = setTimeout(() => { if (this.connectionState === 'connecting') { - console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`); + logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`); this.socket.close(); this.handleConnectionError('Connection timeout'); } @@ -244,13 +258,13 @@ export function initializeTerminalComponent() { this.socket.onclose = this.handleSocketClose.bind(this); } catch (error) { - console.error('[Terminal] Failed to create WebSocket:', error); + logTerminal('error', '[Terminal] Failed to create WebSocket:', error); this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`); } }, handleSocketOpen() { - console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.'); + logTerminal('log', '[Terminal] WebSocket connection established.'); this.connectionState = 'connected'; this.reconnectAttempts = 0; this.heartbeatMissed = 0; @@ -262,6 +276,12 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } + // Flush any buffered command from before WebSocket was ready + if (this.pendingCommand) { + this.sendMessage(this.pendingCommand); + this.pendingCommand = null; + } + // Start ping timeout monitoring this.resetPingTimeout(); @@ -270,16 +290,16 @@ export function initializeTerminalComponent() { }, handleSocketError(error) { - console.error('[Terminal] WebSocket error:', error); - console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); - console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1); + logTerminal('error', '[Terminal] WebSocket error:', error); + logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); + logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); this.handleConnectionError('WebSocket error occurred'); }, handleSocketClose(event) { - console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); - console.log('[Terminal] Was clean close:', event.code === 1000); - console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1); + logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); + logTerminal('log', '[Terminal] Was clean close:', event.code === 1000); + logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); this.connectionState = 'disconnected'; this.clearAllTimers(); @@ -297,7 +317,7 @@ export function initializeTerminalComponent() { }, handleConnectionError(reason) { - console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); + logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); this.connectionState = 'disconnected'; // Only dispatch error to UI after a few failed attempts to avoid immediate error on page load @@ -310,7 +330,7 @@ export function initializeTerminalComponent() { scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('[Terminal] Max reconnection attempts reached'); + logTerminal('error', '[Terminal] Max reconnection attempts reached'); this.message = '(connection failed - max retries exceeded)'; return; } @@ -323,7 +343,7 @@ export function initializeTerminalComponent() { this.maxReconnectDelay ); - console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); + logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); this.reconnectInterval = setTimeout(() => { this.reconnectAttempts++; @@ -335,17 +355,21 @@ export function initializeTerminalComponent() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } else { - console.warn('[Terminal] WebSocket not ready, message not sent:', message); + logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } }, sendCommandWhenReady(message) { if (this.isWebSocketReady()) { this.sendMessage(message); + } else { + this.pendingCommand = message; } }, handleSocketMessage(event) { + logTerminal('log', '[Terminal] Received WebSocket message:', event.data); + // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; @@ -354,6 +378,10 @@ export function initializeTerminalComponent() { return; } + if (!this.term?._initialized && event.data !== 'pty-ready') { + logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data); + } + if (event.data === 'pty-ready') { if (!this.term._initialized) { this.term.open(document.getElementById('terminal')); @@ -398,17 +426,24 @@ export function initializeTerminalComponent() { // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if ( + typeof event.data === 'string' && + (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) + ) { + logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); + this.$wire.dispatch('error', event.data); + this.terminalActive = false; } else { try { this.pendingWrites++; this.term.write(event.data, (err) => { if (err) { - console.error('[Terminal] Write error:', err); + logTerminal('error', '[Terminal] Write error:', err); } this.flowControlCallback(); }); } catch (error) { - console.error('[Terminal] Write operation failed:', error); + logTerminal('error', '[Terminal] Write operation failed:', error); this.pendingWrites = Math.max(0, this.pendingWrites - 1); } } @@ -483,10 +518,10 @@ export function initializeTerminalComponent() { clearTimeout(this.pingTimeoutId); this.pingTimeoutId = null; } - console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); + logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring'); } else if (wasVisible === false) { // Tab is now visible again - console.log('[Terminal] Tab visible, resuming connection management'); + logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { // Send immediate ping to verify connection is still alive @@ -508,10 +543,10 @@ export function initializeTerminalComponent() { this.pingTimeoutId = setTimeout(() => { this.heartbeatMissed++; - console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); + logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); if (this.heartbeatMissed >= this.maxHeartbeatMisses) { - console.error('[Terminal] Too many missed heartbeats, closing connection'); + logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection'); this.socket.close(1001, 'Heartbeat timeout'); } }, this.pingTimeout); @@ -553,7 +588,7 @@ export function initializeTerminalComponent() { // Check if dimensions are valid if (height <= 0 || width <= 0) { - console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); + logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); setTimeout(() => this.resizeTerminal(), 100); return; } @@ -562,7 +597,7 @@ export function initializeTerminalComponent() { if (!charSize.height || !charSize.width) { // Fallback values if char size not available yet - console.warn('[Terminal] Character size not available, retrying...'); + logTerminal('warn', '[Terminal] Character size not available, retrying...'); setTimeout(() => this.resizeTerminal(), 100); return; } @@ -583,10 +618,10 @@ export function initializeTerminalComponent() { }); } } else { - console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); + logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); } } catch (error) { - console.error('[Terminal] Resize error:', error); + logTerminal('error', '[Terminal] Resize error:', error); } }, diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 0cf1a2772..f7ab57a14 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -1,7 +1,9 @@ environment('local') ? $localValue : ''); +if (! function_exists('getOldOrLocal')) { + function getOldOrLocal($key, $localValue) + { + return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : ''); + } } $name = getOldOrLocal('name', 'test3 normal user'); diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 26b1cedf5..85e8f7431 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -4,7 +4,7 @@ @if ( (data_get($application, 'fqdn') || - collect(json_decode($this->application->docker_compose_domains))->count() > 0 || + collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) || data_get($application, 'previews', collect([]))->count() > 0 || data_get($application, 'ports_mappings_array')) && data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true) diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php index 666b93dbc..2bb917f79 100644 --- a/resources/views/components/dropdown.blade.php +++ b/resources/views/components/dropdown.blade.php @@ -1,7 +1,44 @@
- -
+ :style="panelStyles" class="absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6" x-cloak>
+ class="border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200"> {{ $slot }}
diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index b291759a8..29717b9b8 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -11,12 +11,12 @@ ])
$fullWidth, 'dark:hover:bg-coolgray-100 cursor-pointer' => !$disabled, ])> -