diff --git a/.agents/skills/configure-nightwatch/SKILL.md b/.agents/skills/configure-nightwatch/SKILL.md new file mode 100644 index 000000000..1fc580bbd --- /dev/null +++ b/.agents/skills/configure-nightwatch/SKILL.md @@ -0,0 +1,404 @@ +--- +name: configure-nightwatch +description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads. +license: MIT +metadata: + author: laravel +--- + +# Nightwatch Configuration Guide + +This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types. + +## Documentation Reference + +The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of: + +- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction +- Individual event type pages with specific configuration options: + - [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture + - [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction + - [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction + - [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern + - [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling + - [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering + - [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel + - [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling + - [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering +- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist + +## Data Collection Flow + +Nightwatch processes events through three stages: + +1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks) +2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.) +3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information + +``` +Request/Command/Scheduled Task + | + v + [Sampling?] ----NO----> Drop entire trace + | YES + v + Events generated + | + v + [Filtering?] ----YES---> Drop specific event + | NO + v + [Redaction] ----------> Store modified data +``` + +--- + +## Sampling Configuration + +Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured. + +### Global Sample Rates + +Configure via environment variables: + +```bash + +# Default: 100% sampling (all requests/commands captured) + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +``` + +**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs. + +### Route-Based Sampling + +Apply different rates to specific routes using the `Sample` middleware: + +```php routes/web.php +use Illuminate\Support\Facades\Route; +use Laravel\Nightwatch\Http\Middleware\Sample; + +// Sample admin routes at 100% +Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () { + // All admin routes sampled fully +}); + +// Sample API routes at 5% +Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () { + // API routes sampled sparingly +}); + +// Always sample critical endpoints +Route::post('/checkout', [CheckoutController::class, 'process']) + ->middleware(Sample::always()); + +// Never sample health checks +Route::get('/health', [HealthController::class, 'check']) + ->middleware(Sample::never()); +``` + +### Unmatched Route Sampling + +Handle 404/bot traffic with reduced sampling: + +```php routes/web.php +Route::fallback(fn () => abort(404)) + ->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes +``` + +### Dynamic Sampling + +Sample based on runtime conditions (user role, request attributes): + +```php app/Http/Middleware/SampleAdminRequests.php +use Closure; +use Illuminate\Http\Request; +use Laravel\Nightwatch\Facades\Nightwatch; + +class SampleAdminRequests +{ + public function handle(Request $request, Closure $next) + { + if ($request->user()?->isAdmin()) { + Nightwatch::sample(); // Always sample admin requests + } + return $next($request); + } +} +``` + +### Command Sampling + +Exclude specific commands from sampling: + +```php AppServiceProvider.php +use Illuminate\Console\Events\CommandStarting; +use Illuminate\Support\Facades\Event; +use Laravel\Nightwatch\Facades\Nightwatch; + +public function boot(): void +{ + Event::listen(function (CommandStarting $event) { + if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) { + Nightwatch::dontSample(); + } + }); +} +``` + +### Vendor Commands + +Nightwatch automatically ignores framework/internal commands. Opt-in to capture them: + +```php +Nightwatch::captureDefaultVendorCommands(); +``` + +--- + +## Filtering Configuration + +Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage. + +### Database Queries + +**Filter all queries** (disable query collection): + +```bash +NIGHTWATCH_IGNORE_QUERIES=true +``` + +**Filter specific queries** by SQL pattern: + +```php AppServiceProvider.php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Query; + +public function boot(): void +{ + // Filter job table queries (PostgreSQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'into "jobs"'); + }); + + // Filter cache table queries (MySQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'from `cache`') + || str_contains($query->sql, 'into `cache`'); + }); +} +``` + +### Cache Events + +**Filter all cache events**: + +```bash +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +``` + +**Filter by cache key patterns**: + +```php +Nightwatch::rejectCacheKeys([ + 'my-app:users', // Exact match + '/^my-app:posts:/', // Regex: starts with my-app:posts: + '/^[a-zA-Z0-9]{40}$/', // Regex: session IDs +]); +``` + +**Filter with callback**: + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) { + return str_starts_with($cacheEvent->key, 'temp:'); +}); +``` + +### Mail Events + +**Filter all mail**: + +```bash +NIGHTWATCH_IGNORE_MAIL=true +``` + +**Filter specific mail**: + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::rejectMail(function (Mail $mail) { + return str_contains($mail->subject, 'Newsletter'); +}); +``` + +### Notification Events + +**Filter all notifications**: + +```bash +NIGHTWATCH_IGNORE_NOTIFICATIONS=true +``` + +**Filter by channel**: + +```php +use Laravel\Nightwatch\Records\Notification; + +Nightwatch::rejectNotifications(function (Notification $notification) { + return $notification->channel === 'database'; +}); +``` + +### Outgoing HTTP Requests + +**Filter all outgoing requests**: + +```bash +NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true +``` + +**Filter by URL**: + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) { + return str_contains($request->url, 'analytics.example.com'); +}); +``` + +### Queued Jobs + +**Filter specific jobs**: + +```php +use Laravel\Nightwatch\Records\QueuedJob; + +Nightwatch::rejectQueuedJobs(function (QueuedJob $job) { + return $job->name === 'App\Jobs\LowPriorityJob'; +}); +``` + +### Decoupling Job Sampling + +Sample jobs independently from parent contexts: + +```php +use Illuminate\Support\Facades\Queue; + +public function boot(): void +{ + Queue::before(fn () => Nightwatch::sample(rate: 0.5)); +} +``` + +--- + +## Redaction Configuration + +Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content. + +### Request Redaction + +**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN): + +```bash + +# Customize redacted headers + +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key +``` + +**Redact request payloads** (disabled by default): + +```bash + +# Enable payload capture + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true + +# Customize redacted fields + +NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card +``` + +**Programmatic redaction**: + +```php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Request; + +Nightwatch::redactRequests(function (Request $request) { + $request->url = str_replace('secret', '***', $request->url); + $request->ip = preg_replace('/\d+$/', '***', $request->ip); +}); +``` + +### Query Redaction + +```php +use Laravel\Nightwatch\Records\Query; + +Nightwatch::redactQueries(function (Query $query) { + $query->sql = str_replace('secret_token', '***', $query->sql); +}); +``` + +### Cache Redaction + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) { + $cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key); +}); +``` + +### Command Redaction + +```php +use Laravel\Nightwatch\Records\Command; + +Nightwatch::redactCommands(function (Command $command) { + $command->command = preg_replace('/--password=\S+/', '--password=***', $command->command); +}); +``` + +### Exception Redaction + +```php +use Laravel\Nightwatch\Records\Exception; + +Nightwatch::redactExceptions(function (Exception $exception) { + $exception->message = str_replace('secret', '***', $exception->message); +}); +``` + +### Mail Redaction + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::redactMail(function (Mail $mail) { + $mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject); +}); +``` + +### Outgoing Request Redaction + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) { + $outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url); +}); +``` diff --git a/.agents/skills/configure-nightwatch/reference.md b/.agents/skills/configure-nightwatch/reference.md new file mode 100644 index 000000000..b071d2f2e --- /dev/null +++ b/.agents/skills/configure-nightwatch/reference.md @@ -0,0 +1,108 @@ +# Nightwatch Configuration Reference + +## Configuration Summary by Event Type + +| Event Type | Sampling | Filtering | Redaction | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- | +| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP | +| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments | +| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement | +| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key | +| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable | +| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject | +| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable | +| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL | +| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message | + +--- + +## Production Recommendations + +### High-Traffic Applications + +```bash + +# Conservative sampling + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +# Filter noisy events + +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically + +``` + +### Privacy-Conscious Applications + +```bash + +# Disable sensitive data collection + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN + +# Or use redaction in AppServiceProvider + +``` + +### Balanced Configuration (Recommended Start) + +```bash + +# Sample rates + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 + +# Filter obvious noise programmatically + +# Redact PII as needed + +``` + +--- + +## Verification Checklist + +After configuration: + +- [ ] Sampling rates appropriate for traffic volume +- [ ] Noisy events filtered (cache, certain queries) +- [ ] Sensitive data redacted (PII, tokens, credentials) +- [ ] Exceptions always captured for debugging +- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0` +- [ ] Monitor event quota usage in Nightwatch dashboard + +--- + +## Common Patterns + +### Filter Health Checks + Reduce Sampling + +```php +Route::get('/health', fn() => ['status' => 'ok']) + ->middleware(Sample::never()); +``` + +### Exclude Internal/Vendor Queries + +```php +Nightwatch::rejectQueries(fn($q) => + str_contains($q->sql, 'telescope') || + str_contains($q->sql, 'pulse') +); +``` + +### Protect User Data in Cache Keys + +```php +Nightwatch::redactCacheEvents(fn($e) => + $e->key = preg_replace('/user:\d+/', 'user:***', $e->key) +); +``` diff --git a/.agents/skills/configuring-horizon/SKILL.md b/.agents/skills/configuring-horizon/SKILL.md index bed1e74c0..68477acd2 100644 --- a/.agents/skills/configuring-horizon/SKILL.md +++ b/.agents/skills/configuring-horizon/SKILL.md @@ -82,4 +82,4 @@ protected function gate(): void - 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 +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. diff --git a/.agents/skills/configuring-horizon/references/metrics.md b/.agents/skills/configuring-horizon/references/metrics.md index 312f79ee7..7e1aea6bb 100644 --- a/.agents/skills/configuring-horizon/references/metrics.md +++ b/.agents/skills/configuring-horizon/references/metrics.md @@ -18,4 +18,4 @@ A single manual run populates the dashboard momentarily but will not keep it upd ### `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 +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. diff --git a/.agents/skills/configuring-horizon/references/notifications.md b/.agents/skills/configuring-horizon/references/notifications.md index 943d1a26a..d6d3feed9 100644 --- a/.agents/skills/configuring-horizon/references/notifications.md +++ b/.agents/skills/configuring-horizon/references/notifications.md @@ -18,4 +18,4 @@ Configure notifications in the `boot()` method of `App\Providers\HorizonServiceP ### 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 +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. diff --git a/.agents/skills/configuring-horizon/references/supervisors.md b/.agents/skills/configuring-horizon/references/supervisors.md index 9da0c1769..b71285cfd 100644 --- a/.agents/skills/configuring-horizon/references/supervisors.md +++ b/.agents/skills/configuring-horizon/references/supervisors.md @@ -24,4 +24,4 @@ Auto-balancing suits variable load, but if a queue should always have exactly N ### 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 +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. diff --git a/.agents/skills/configuring-horizon/references/tags.md b/.agents/skills/configuring-horizon/references/tags.md index 263c955c1..8234e4adb 100644 --- a/.agents/skills/configuring-horizon/references/tags.md +++ b/.agents/skills/configuring-horizon/references/tags.md @@ -18,4 +18,4 @@ Adding a job class to the `silenced` array in `config/horizon.php` removes it fr ### `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 +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. diff --git a/.agents/skills/debugging-output-and-previewing-html-using-ray/SKILL.md b/.agents/skills/debugging-output-and-previewing-html-using-ray/SKILL.md index 4583bd56e..eecbb3662 100644 --- a/.agents/skills/debugging-output-and-previewing-html-using-ray/SKILL.md +++ b/.agents/skills/debugging-output-and-previewing-html-using-ray/SKILL.md @@ -411,4 +411,4 @@ curl -X POST http://localhost:23517/ \ | `remove` | (empty) | Remove entry | | `confetti` | (empty) | Confetti animation | | `show_app` | (empty) | Show Ray window | -| `hide_app` | (empty) | Hide Ray window | \ No newline at end of file +| `hide_app` | (empty) | Hide Ray window | diff --git a/.agents/skills/fortify-development/SKILL.md b/.agents/skills/fortify-development/SKILL.md index 86322d9c0..2c4e84fe4 100644 --- a/.agents/skills/fortify-development/SKILL.md +++ b/.agents/skills/fortify-development/SKILL.md @@ -1,6 +1,6 @@ --- 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.' +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), passkeys, 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, passkeys, WebAuthn, 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 @@ -32,6 +32,7 @@ Enable in `config/fortify.php` features array: - `Features::updateProfileInformation()` - Profile updates - `Features::updatePasswords()` - Password changes - `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes +- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys > Use `search-docs` for feature configuration options and customization patterns. @@ -50,6 +51,18 @@ Enable in `config/fortify.php` features array: > Use `search-docs` for TOTP implementation and recovery code handling patterns. +### Passkeys Setup + +``` +- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser +- [ ] Enable passkeys feature in config/fortify.php +- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate +- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable +- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion +``` + +> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm. + ### Email Verification Setup ``` @@ -128,4 +141,11 @@ Configure via `fortify.limiters.login` in config. Default configuration throttle | Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | | 2FA Challenge | POST | `/two-factor-challenge` | | Get QR Code | GET | `/user/two-factor-qr-code` | -| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | +| Passkey Login Options | GET | `/passkeys/login/options` | +| Passkey Login | POST | `/passkeys/login` | +| Passkey Confirm Options| GET | `/passkeys/confirm/options` | +| Passkey Confirm | POST | `/passkeys/confirm` | +| Passkey Options | GET | `/user/passkeys/options` | +| Register Passkey | POST | `/user/passkeys` | +| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | diff --git a/.agents/skills/laravel-actions/SKILL.md b/.agents/skills/laravel-actions/SKILL.md index 862dd55b5..297475c0f 100644 --- a/.agents/skills/laravel-actions/SKILL.md +++ b/.agents/skills/laravel-actions/SKILL.md @@ -299,4 +299,4 @@ Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused - 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 +- Troubleshooting: `references/troubleshooting.md` diff --git a/.agents/skills/laravel-actions/references/command.md b/.agents/skills/laravel-actions/references/command.md index a7b255daf..3fbe31c39 100644 --- a/.agents/skills/laravel-actions/references/command.md +++ b/.agents/skills/laravel-actions/references/command.md @@ -157,4 +157,4 @@ $this->artisan('users:update-role 1 admin') ## References -- https://www.laravelactions.com/2.x/as-command.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-command.html diff --git a/.agents/skills/laravel-actions/references/controller.md b/.agents/skills/laravel-actions/references/controller.md index d48c34df8..9f3e88906 100644 --- a/.agents/skills/laravel-actions/references/controller.md +++ b/.agents/skills/laravel-actions/references/controller.md @@ -336,4 +336,4 @@ public function getAuthorizationFailure(): void ## References -- https://www.laravelactions.com/2.x/as-controller.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-controller.html diff --git a/.agents/skills/laravel-actions/references/job.md b/.agents/skills/laravel-actions/references/job.md index b4c7cbea0..8954b4c94 100644 --- a/.agents/skills/laravel-actions/references/job.md +++ b/.agents/skills/laravel-actions/references/job.md @@ -422,4 +422,4 @@ public function jobFailed(?Throwable $e, ...$parameters): void ## References -- https://www.laravelactions.com/2.x/as-job.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-job.html diff --git a/.agents/skills/laravel-actions/references/listener.md b/.agents/skills/laravel-actions/references/listener.md index c5233001d..dad01ab92 100644 --- a/.agents/skills/laravel-actions/references/listener.md +++ b/.agents/skills/laravel-actions/references/listener.md @@ -78,4 +78,4 @@ Event::assertDispatched(TaxiRequested::class); ## References -- https://www.laravelactions.com/2.x/as-listener.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-listener.html diff --git a/.agents/skills/laravel-actions/references/object.md b/.agents/skills/laravel-actions/references/object.md index 6a90be4d5..27ff6bd54 100644 --- a/.agents/skills/laravel-actions/references/object.md +++ b/.agents/skills/laravel-actions/references/object.md @@ -115,4 +115,4 @@ final class ArticleService 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 index 97766e6ce..eedb9f506 100644 --- a/.agents/skills/laravel-actions/references/testing-fakes.md +++ b/.agents/skills/laravel-actions/references/testing-fakes.md @@ -157,4 +157,4 @@ it('does not run sync when integration is disabled', function () { ## References -- https://www.laravelactions.com/2.x/as-fake.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-fake.html diff --git a/.agents/skills/laravel-actions/references/troubleshooting.md b/.agents/skills/laravel-actions/references/troubleshooting.md index cf6a5800f..d94114ff0 100644 --- a/.agents/skills/laravel-actions/references/troubleshooting.md +++ b/.agents/skills/laravel-actions/references/troubleshooting.md @@ -30,4 +30,4 @@ Use this reference when action wiring behaves unexpectedly. - 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 +- Isolate dependencies with fakes/spies where appropriate. diff --git a/.agents/skills/laravel-actions/references/with-attributes.md b/.agents/skills/laravel-actions/references/with-attributes.md index 1b28cf2cb..f9a234394 100644 --- a/.agents/skills/laravel-actions/references/with-attributes.md +++ b/.agents/skills/laravel-actions/references/with-attributes.md @@ -186,4 +186,4 @@ $article = $action->handle($validated); ## References -- https://www.laravelactions.com/2.x/with-attributes.html \ No newline at end of file +- https://www.laravelactions.com/2.x/with-attributes.html diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md index 99018f3ae..965e267e1 100644 --- a/.agents/skills/laravel-best-practices/SKILL.md +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -94,7 +94,7 @@ Check sibling files, related controllers, models, or tests for established patte ### 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 +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release - 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 @@ -187,4 +187,4 @@ 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 +3. Verify API syntax with `search-docs` for the installed Laravel version diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md index 920714a14..f12876e4c 100644 --- a/.agents/skills/laravel-best-practices/rules/advanced-queries.md +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -103,4 +103,4 @@ public function scopeOrderByLastLogin($query): void ->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 index 165056422..51c6e65dc 100644 --- a/.agents/skills/laravel-best-practices/rules/architecture.md +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -82,7 +82,7 @@ $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. +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. Incorrect: ```php @@ -199,4 +199,4 @@ class Customer extends Model 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 index c6f8aaf1e..5f0b3a1e3 100644 --- a/.agents/skills/laravel-best-practices/rules/blade-views.md +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -33,4 +33,4 @@ return view('dashboard', compact('users')) ## Use `@aware` for Deeply Nested Component Props -Avoids re-passing parent props through every level of nested components. \ No newline at end of file +Avoids re-passing parent props through every level of nested components. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md index eb3ef3e62..67408d6e1 100644 --- a/.agents/skills/laravel-best-practices/rules/caching.md +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -2,7 +2,7 @@ ## Use `Cache::remember()` Instead of Manual Get/Put -Atomic pattern prevents race conditions and removes boilerplate. +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. Incorrect: ```php @@ -67,4 +67,4 @@ 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 index 14f683d32..18e8d9e1d 100644 --- a/.agents/skills/laravel-best-practices/rules/collections.md +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -41,4 +41,4 @@ 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 index 8fd8f536f..9bea727b3 100644 --- a/.agents/skills/laravel-best-practices/rules/config.md +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -2,7 +2,7 @@ ## `env()` Only in Config Files -Direct `env()` calls return `null` when config is cached. +Direct `env()` calls may return `null` when config is cached. Incorrect: ```php @@ -70,4 +70,4 @@ If the application already uses language files for localization, use `__()` for ```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 index 8fb719377..c49ba164e 100644 --- a/.agents/skills/laravel-best-practices/rules/db-performance.md +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -189,4 +189,4 @@ return view('users.index', compact('users')); @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 index 09cd66a05..413d5da42 100644 --- a/.agents/skills/laravel-best-practices/rules/eloquent.md +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -145,4 +145,4 @@ 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 +**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. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md index bb8e7a387..4b1486676 100644 --- a/.agents/skills/laravel-best-practices/rules/error-handling.md +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -69,4 +69,4 @@ class InvalidOrderException extends Exception 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 index bc43f1997..82e329e8f 100644 --- a/.agents/skills/laravel-best-practices/rules/events-notifications.md +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -29,7 +29,11 @@ class InvoicePaid extends Notification implements ShouldQueue ## Use `afterCommit()` on Notifications in Transactions -Same race condition as events — the queued notification job may run before the transaction commits. +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` ## Route Notification Channels to Dedicated Queues @@ -45,4 +49,4 @@ 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 +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md index 0a7876ed3..8e2f16e8a 100644 --- a/.agents/skills/laravel-best-practices/rules/http-client.md +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -52,7 +52,7 @@ $response = Http::retry([100, 500, 1000]) Only retry on specific errors: ```php -$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { return $exception instanceof ConnectionException || ($exception instanceof RequestException && $exception->response->serverError()); })->post('https://api.example.com/data'); @@ -157,4 +157,4 @@ Test failure scenarios too: 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 index c7f67966e..7c717336d 100644 --- a/.agents/skills/laravel-best-practices/rules/mail.md +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -10,7 +10,7 @@ A queued mailable dispatched inside a transaction may process before the commit. ## Use `assertQueued()` Not `assertSent()` for Queued Mailables -`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. @@ -24,4 +24,4 @@ Markdown mailables auto-generate both HTML and plain-text versions, use responsi 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 +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md index de25aa39c..df6f5f33c 100644 --- a/.agents/skills/laravel-best-practices/rules/migrations.md +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -118,4 +118,4 @@ 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 index d4575aac0..c41915e2b 100644 --- a/.agents/skills/laravel-best-practices/rules/queue-jobs.md +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -106,25 +106,23 @@ When using time-based retry limits, set `$tries = 0` to avoid premature failure. ```php public $tries = 0; -public function retryUntil(): DateTime +public function retryUntil(): \DateTimeInterface { return now()->addHours(4); } ``` -## Use `WithoutOverlapping::untilProcessing()` +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release -Prevents concurrent execution while allowing new instances to queue. +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. ```php -public function middleware(): array +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing { - return [new WithoutOverlapping($this->product->id)->untilProcessing()]; + // Lock releases when processing begins, not when it finishes } ``` -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. @@ -143,4 +141,4 @@ Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or ], ], ], -``` \ 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 index e288375d7..b6e30864f 100644 --- a/.agents/skills/laravel-best-practices/rules/routing.md +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -36,7 +36,8 @@ Use `Route::resource()` or `apiResource()` for RESTful endpoints. ```php Route::resource('posts', PostController::class); -Route::apiResource('api/posts', Api\PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); ``` ## Keep Controllers Thin @@ -95,4 +96,4 @@ public function store(StorePostRequest $request): RedirectResponse 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 index dfaefa26f..a98479450 100644 --- a/.agents/skills/laravel-best-practices/rules/scheduling.md +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -36,4 +36,4 @@ Schedule::daily() 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 index 524d47e61..2d7200c29 100644 --- a/.agents/skills/laravel-best-practices/rules/security.md +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -32,7 +32,7 @@ Use policies or gates in controllers. Never skip authorization. Incorrect: ```php -public function update(Request $request, Post $post) +public function update(UpdatePostRequest $request, Post $post) { $post->update($request->validated()); } @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. Incorrect: ```blade @@ -121,7 +121,7 @@ Route::post('/login', LoginController::class)->middleware('throttle:login'); ## Validate File Uploads -Validate MIME type, extension, and size. Never trust client-provided filenames. +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. ```php public function rules(): array @@ -195,4 +195,4 @@ class Integration extends Model ]; } } -``` \ 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 index db689bf77..64d173081 100644 Binary files a/.agents/skills/laravel-best-practices/rules/style.md 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 index d39cc3ed0..4fbf12f8a 100644 --- a/.agents/skills/laravel-best-practices/rules/testing.md +++ b/.agents/skills/laravel-best-practices/rules/testing.md @@ -2,7 +2,7 @@ ## 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. +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. ## Use Model Assertions Over Raw Database Assertions @@ -40,4 +40,4 @@ Without `recycle()`, nested factories create separate instances of the same conc 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 index a20202ff1..5fde1064a 100644 --- a/.agents/skills/laravel-best-practices/rules/validation.md +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -72,4 +72,4 @@ public function after(): array }, ]; } -``` \ No newline at end of file +``` diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md index 70ecd57d4..7a86d368e 100644 --- a/.agents/skills/livewire-development/SKILL.md +++ b/.agents/skills/livewire-development/SKILL.md @@ -112,4 +112,4 @@ $this->get('/posts/create') - Forgetting `wire:key` in loops causes unexpected behavior when items change - Using `wire:model` expecting real-time updates (use `wire:model.live` instead in v3) - Not validating/authorizing in Livewire actions (treat them like HTTP requests) -- Including Alpine.js separately when it's already bundled with Livewire 3 \ No newline at end of file +- Including Alpine.js separately when it's already bundled with Livewire 3 diff --git a/.agents/skills/mcp-development/SKILL.md b/.agents/skills/mcp-development/SKILL.md new file mode 100644 index 000000000..dcfb13004 --- /dev/null +++ b/.agents/skills/mcp-development/SKILL.md @@ -0,0 +1,96 @@ +--- +name: mcp-development +description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP." +license: MIT +metadata: + author: laravel +--- + +# MCP Development + +## Documentation + +Use `search-docs` for detailed Laravel MCP patterns and documentation. + +## Basic Usage + +Register MCP servers in `routes/ai.php`: + + +```php +use Laravel\Mcp\Facades\Mcp; + +Mcp::web(); +``` + +### Creating MCP Primitives + +Create MCP tools, resources, prompts, and servers using artisan commands: + +```bash +php artisan make:mcp-tool ToolName # Create a tool + +php artisan make:mcp-resource ResourceName # Create a resource + +php artisan make:mcp-prompt PromptName # Create a prompt + +php artisan make:mcp-server ServerName # Create a server + +``` + +After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties. + +### Tools + + +```php +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Request; +use Laravel\Mcp\Server\Response; + +class MyTool extends Tool +{ + public function handle(Request $request): Response + { + return new Response(['result' => 'success']); + } +} +``` + +### Registering Primitives in a Server + +Each MCP server must explicitly declare the tools, resources, and prompts it exposes. + + +```php +use Laravel\Mcp\Server; + +class AppServer extends Server +{ + protected array $tools = [ + \App\Mcp\Tools\MyTool::class, + ]; + + protected array $resources = [ + \App\Mcp\Resources\MyResource::class, + ]; + + protected array $prompts = [ + \App\Mcp\Prompts\MyPrompt::class, + ]; +} +``` + +## Verification + +1. Check `routes/ai.php` for proper registration +2. Test tool via MCP client + +## Common Pitfalls + +- Running `mcp:start` command (it hangs waiting for input) +- Using HTTPS locally with Node-based MCP clients +- Not using `search-docs` for the latest MCP documentation +- Not registering MCP server routes in `routes/ai.php` +- Do not register `ai.php` in `bootstrap.php`; it is registered automatically. +- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md index ba774e71b..ab2716165 100644 --- a/.agents/skills/pest-testing/SKILL.md +++ b/.agents/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -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." +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: test()/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 @@ -18,6 +18,12 @@ Use `search-docs` for detailed Pest 4 patterns and documentation. All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + ### Test Organization - Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. @@ -26,6 +32,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { @@ -154,4 +162,5 @@ arch('controllers') - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.agents/skills/socialite-development/SKILL.md b/.agents/skills/socialite-development/SKILL.md index e660da691..562e417af 100644 --- a/.agents/skills/socialite-development/SKILL.md +++ b/.agents/skills/socialite-development/SKILL.md @@ -77,4 +77,4 @@ Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use - 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 +- `user()` throws when the user declines authorization. Always handle denied grants. diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md index 7c8e295e8..c0cb2fbcd 100644 --- a/.agents/skills/tailwindcss-development/SKILL.md +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -116,4 +116,4 @@ If existing pages and components support dark mode, new pages and components mus - Using `@tailwind` directives instead of `@import "tailwindcss"` - Trying to use `tailwind.config.js` instead of CSS `@theme` directive - Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.claude/skills/configure-nightwatch/SKILL.md b/.claude/skills/configure-nightwatch/SKILL.md new file mode 100644 index 000000000..1fc580bbd --- /dev/null +++ b/.claude/skills/configure-nightwatch/SKILL.md @@ -0,0 +1,404 @@ +--- +name: configure-nightwatch +description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads. +license: MIT +metadata: + author: laravel +--- + +# Nightwatch Configuration Guide + +This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types. + +## Documentation Reference + +The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of: + +- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction +- Individual event type pages with specific configuration options: + - [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture + - [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction + - [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction + - [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern + - [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling + - [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering + - [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel + - [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling + - [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering +- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist + +## Data Collection Flow + +Nightwatch processes events through three stages: + +1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks) +2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.) +3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information + +``` +Request/Command/Scheduled Task + | + v + [Sampling?] ----NO----> Drop entire trace + | YES + v + Events generated + | + v + [Filtering?] ----YES---> Drop specific event + | NO + v + [Redaction] ----------> Store modified data +``` + +--- + +## Sampling Configuration + +Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured. + +### Global Sample Rates + +Configure via environment variables: + +```bash + +# Default: 100% sampling (all requests/commands captured) + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +``` + +**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs. + +### Route-Based Sampling + +Apply different rates to specific routes using the `Sample` middleware: + +```php routes/web.php +use Illuminate\Support\Facades\Route; +use Laravel\Nightwatch\Http\Middleware\Sample; + +// Sample admin routes at 100% +Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () { + // All admin routes sampled fully +}); + +// Sample API routes at 5% +Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () { + // API routes sampled sparingly +}); + +// Always sample critical endpoints +Route::post('/checkout', [CheckoutController::class, 'process']) + ->middleware(Sample::always()); + +// Never sample health checks +Route::get('/health', [HealthController::class, 'check']) + ->middleware(Sample::never()); +``` + +### Unmatched Route Sampling + +Handle 404/bot traffic with reduced sampling: + +```php routes/web.php +Route::fallback(fn () => abort(404)) + ->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes +``` + +### Dynamic Sampling + +Sample based on runtime conditions (user role, request attributes): + +```php app/Http/Middleware/SampleAdminRequests.php +use Closure; +use Illuminate\Http\Request; +use Laravel\Nightwatch\Facades\Nightwatch; + +class SampleAdminRequests +{ + public function handle(Request $request, Closure $next) + { + if ($request->user()?->isAdmin()) { + Nightwatch::sample(); // Always sample admin requests + } + return $next($request); + } +} +``` + +### Command Sampling + +Exclude specific commands from sampling: + +```php AppServiceProvider.php +use Illuminate\Console\Events\CommandStarting; +use Illuminate\Support\Facades\Event; +use Laravel\Nightwatch\Facades\Nightwatch; + +public function boot(): void +{ + Event::listen(function (CommandStarting $event) { + if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) { + Nightwatch::dontSample(); + } + }); +} +``` + +### Vendor Commands + +Nightwatch automatically ignores framework/internal commands. Opt-in to capture them: + +```php +Nightwatch::captureDefaultVendorCommands(); +``` + +--- + +## Filtering Configuration + +Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage. + +### Database Queries + +**Filter all queries** (disable query collection): + +```bash +NIGHTWATCH_IGNORE_QUERIES=true +``` + +**Filter specific queries** by SQL pattern: + +```php AppServiceProvider.php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Query; + +public function boot(): void +{ + // Filter job table queries (PostgreSQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'into "jobs"'); + }); + + // Filter cache table queries (MySQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'from `cache`') + || str_contains($query->sql, 'into `cache`'); + }); +} +``` + +### Cache Events + +**Filter all cache events**: + +```bash +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +``` + +**Filter by cache key patterns**: + +```php +Nightwatch::rejectCacheKeys([ + 'my-app:users', // Exact match + '/^my-app:posts:/', // Regex: starts with my-app:posts: + '/^[a-zA-Z0-9]{40}$/', // Regex: session IDs +]); +``` + +**Filter with callback**: + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) { + return str_starts_with($cacheEvent->key, 'temp:'); +}); +``` + +### Mail Events + +**Filter all mail**: + +```bash +NIGHTWATCH_IGNORE_MAIL=true +``` + +**Filter specific mail**: + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::rejectMail(function (Mail $mail) { + return str_contains($mail->subject, 'Newsletter'); +}); +``` + +### Notification Events + +**Filter all notifications**: + +```bash +NIGHTWATCH_IGNORE_NOTIFICATIONS=true +``` + +**Filter by channel**: + +```php +use Laravel\Nightwatch\Records\Notification; + +Nightwatch::rejectNotifications(function (Notification $notification) { + return $notification->channel === 'database'; +}); +``` + +### Outgoing HTTP Requests + +**Filter all outgoing requests**: + +```bash +NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true +``` + +**Filter by URL**: + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) { + return str_contains($request->url, 'analytics.example.com'); +}); +``` + +### Queued Jobs + +**Filter specific jobs**: + +```php +use Laravel\Nightwatch\Records\QueuedJob; + +Nightwatch::rejectQueuedJobs(function (QueuedJob $job) { + return $job->name === 'App\Jobs\LowPriorityJob'; +}); +``` + +### Decoupling Job Sampling + +Sample jobs independently from parent contexts: + +```php +use Illuminate\Support\Facades\Queue; + +public function boot(): void +{ + Queue::before(fn () => Nightwatch::sample(rate: 0.5)); +} +``` + +--- + +## Redaction Configuration + +Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content. + +### Request Redaction + +**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN): + +```bash + +# Customize redacted headers + +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key +``` + +**Redact request payloads** (disabled by default): + +```bash + +# Enable payload capture + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true + +# Customize redacted fields + +NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card +``` + +**Programmatic redaction**: + +```php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Request; + +Nightwatch::redactRequests(function (Request $request) { + $request->url = str_replace('secret', '***', $request->url); + $request->ip = preg_replace('/\d+$/', '***', $request->ip); +}); +``` + +### Query Redaction + +```php +use Laravel\Nightwatch\Records\Query; + +Nightwatch::redactQueries(function (Query $query) { + $query->sql = str_replace('secret_token', '***', $query->sql); +}); +``` + +### Cache Redaction + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) { + $cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key); +}); +``` + +### Command Redaction + +```php +use Laravel\Nightwatch\Records\Command; + +Nightwatch::redactCommands(function (Command $command) { + $command->command = preg_replace('/--password=\S+/', '--password=***', $command->command); +}); +``` + +### Exception Redaction + +```php +use Laravel\Nightwatch\Records\Exception; + +Nightwatch::redactExceptions(function (Exception $exception) { + $exception->message = str_replace('secret', '***', $exception->message); +}); +``` + +### Mail Redaction + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::redactMail(function (Mail $mail) { + $mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject); +}); +``` + +### Outgoing Request Redaction + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) { + $outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url); +}); +``` diff --git a/.claude/skills/configure-nightwatch/reference.md b/.claude/skills/configure-nightwatch/reference.md new file mode 100644 index 000000000..b071d2f2e --- /dev/null +++ b/.claude/skills/configure-nightwatch/reference.md @@ -0,0 +1,108 @@ +# Nightwatch Configuration Reference + +## Configuration Summary by Event Type + +| Event Type | Sampling | Filtering | Redaction | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- | +| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP | +| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments | +| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement | +| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key | +| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable | +| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject | +| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable | +| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL | +| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message | + +--- + +## Production Recommendations + +### High-Traffic Applications + +```bash + +# Conservative sampling + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +# Filter noisy events + +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically + +``` + +### Privacy-Conscious Applications + +```bash + +# Disable sensitive data collection + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN + +# Or use redaction in AppServiceProvider + +``` + +### Balanced Configuration (Recommended Start) + +```bash + +# Sample rates + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 + +# Filter obvious noise programmatically + +# Redact PII as needed + +``` + +--- + +## Verification Checklist + +After configuration: + +- [ ] Sampling rates appropriate for traffic volume +- [ ] Noisy events filtered (cache, certain queries) +- [ ] Sensitive data redacted (PII, tokens, credentials) +- [ ] Exceptions always captured for debugging +- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0` +- [ ] Monitor event quota usage in Nightwatch dashboard + +--- + +## Common Patterns + +### Filter Health Checks + Reduce Sampling + +```php +Route::get('/health', fn() => ['status' => 'ok']) + ->middleware(Sample::never()); +``` + +### Exclude Internal/Vendor Queries + +```php +Nightwatch::rejectQueries(fn($q) => + str_contains($q->sql, 'telescope') || + str_contains($q->sql, 'pulse') +); +``` + +### Protect User Data in Cache Keys + +```php +Nightwatch::redactCacheEvents(fn($e) => + $e->key = preg_replace('/user:\d+/', 'user:***', $e->key) +); +``` diff --git a/.claude/skills/configuring-horizon/SKILL.md b/.claude/skills/configuring-horizon/SKILL.md index bed1e74c0..68477acd2 100644 --- a/.claude/skills/configuring-horizon/SKILL.md +++ b/.claude/skills/configuring-horizon/SKILL.md @@ -82,4 +82,4 @@ protected function gate(): void - 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 +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. diff --git a/.claude/skills/configuring-horizon/references/metrics.md b/.claude/skills/configuring-horizon/references/metrics.md index 312f79ee7..7e1aea6bb 100644 --- a/.claude/skills/configuring-horizon/references/metrics.md +++ b/.claude/skills/configuring-horizon/references/metrics.md @@ -18,4 +18,4 @@ A single manual run populates the dashboard momentarily but will not keep it upd ### `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 +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. diff --git a/.claude/skills/configuring-horizon/references/notifications.md b/.claude/skills/configuring-horizon/references/notifications.md index 943d1a26a..d6d3feed9 100644 --- a/.claude/skills/configuring-horizon/references/notifications.md +++ b/.claude/skills/configuring-horizon/references/notifications.md @@ -18,4 +18,4 @@ Configure notifications in the `boot()` method of `App\Providers\HorizonServiceP ### 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 +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. diff --git a/.claude/skills/configuring-horizon/references/supervisors.md b/.claude/skills/configuring-horizon/references/supervisors.md index 9da0c1769..b71285cfd 100644 --- a/.claude/skills/configuring-horizon/references/supervisors.md +++ b/.claude/skills/configuring-horizon/references/supervisors.md @@ -24,4 +24,4 @@ Auto-balancing suits variable load, but if a queue should always have exactly N ### 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 +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. diff --git a/.claude/skills/configuring-horizon/references/tags.md b/.claude/skills/configuring-horizon/references/tags.md index 263c955c1..8234e4adb 100644 --- a/.claude/skills/configuring-horizon/references/tags.md +++ b/.claude/skills/configuring-horizon/references/tags.md @@ -18,4 +18,4 @@ Adding a job class to the `silenced` array in `config/horizon.php` removes it fr ### `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 +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. diff --git a/.claude/skills/debugging-output-and-previewing-html-using-ray/SKILL.md b/.claude/skills/debugging-output-and-previewing-html-using-ray/SKILL.md index 4583bd56e..eecbb3662 100644 --- a/.claude/skills/debugging-output-and-previewing-html-using-ray/SKILL.md +++ b/.claude/skills/debugging-output-and-previewing-html-using-ray/SKILL.md @@ -411,4 +411,4 @@ curl -X POST http://localhost:23517/ \ | `remove` | (empty) | Remove entry | | `confetti` | (empty) | Confetti animation | | `show_app` | (empty) | Show Ray window | -| `hide_app` | (empty) | Hide Ray window | \ No newline at end of file +| `hide_app` | (empty) | Hide Ray window | diff --git a/.claude/skills/fortify-development/SKILL.md b/.claude/skills/fortify-development/SKILL.md index 86322d9c0..2c4e84fe4 100644 --- a/.claude/skills/fortify-development/SKILL.md +++ b/.claude/skills/fortify-development/SKILL.md @@ -1,6 +1,6 @@ --- 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.' +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), passkeys, 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, passkeys, WebAuthn, 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 @@ -32,6 +32,7 @@ Enable in `config/fortify.php` features array: - `Features::updateProfileInformation()` - Profile updates - `Features::updatePasswords()` - Password changes - `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes +- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys > Use `search-docs` for feature configuration options and customization patterns. @@ -50,6 +51,18 @@ Enable in `config/fortify.php` features array: > Use `search-docs` for TOTP implementation and recovery code handling patterns. +### Passkeys Setup + +``` +- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser +- [ ] Enable passkeys feature in config/fortify.php +- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate +- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable +- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion +``` + +> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm. + ### Email Verification Setup ``` @@ -128,4 +141,11 @@ Configure via `fortify.limiters.login` in config. Default configuration throttle | Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | | 2FA Challenge | POST | `/two-factor-challenge` | | Get QR Code | GET | `/user/two-factor-qr-code` | -| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | +| Passkey Login Options | GET | `/passkeys/login/options` | +| Passkey Login | POST | `/passkeys/login` | +| Passkey Confirm Options| GET | `/passkeys/confirm/options` | +| Passkey Confirm | POST | `/passkeys/confirm` | +| Passkey Options | GET | `/user/passkeys/options` | +| Register Passkey | POST | `/user/passkeys` | +| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | diff --git a/.claude/skills/laravel-actions/SKILL.md b/.claude/skills/laravel-actions/SKILL.md index 862dd55b5..297475c0f 100644 --- a/.claude/skills/laravel-actions/SKILL.md +++ b/.claude/skills/laravel-actions/SKILL.md @@ -299,4 +299,4 @@ Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused - 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 +- Troubleshooting: `references/troubleshooting.md` diff --git a/.claude/skills/laravel-actions/references/command.md b/.claude/skills/laravel-actions/references/command.md index a7b255daf..3fbe31c39 100644 --- a/.claude/skills/laravel-actions/references/command.md +++ b/.claude/skills/laravel-actions/references/command.md @@ -157,4 +157,4 @@ $this->artisan('users:update-role 1 admin') ## References -- https://www.laravelactions.com/2.x/as-command.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-command.html diff --git a/.claude/skills/laravel-actions/references/controller.md b/.claude/skills/laravel-actions/references/controller.md index d48c34df8..9f3e88906 100644 --- a/.claude/skills/laravel-actions/references/controller.md +++ b/.claude/skills/laravel-actions/references/controller.md @@ -336,4 +336,4 @@ public function getAuthorizationFailure(): void ## References -- https://www.laravelactions.com/2.x/as-controller.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-controller.html diff --git a/.claude/skills/laravel-actions/references/job.md b/.claude/skills/laravel-actions/references/job.md index b4c7cbea0..8954b4c94 100644 --- a/.claude/skills/laravel-actions/references/job.md +++ b/.claude/skills/laravel-actions/references/job.md @@ -422,4 +422,4 @@ public function jobFailed(?Throwable $e, ...$parameters): void ## References -- https://www.laravelactions.com/2.x/as-job.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-job.html diff --git a/.claude/skills/laravel-actions/references/listener.md b/.claude/skills/laravel-actions/references/listener.md index c5233001d..dad01ab92 100644 --- a/.claude/skills/laravel-actions/references/listener.md +++ b/.claude/skills/laravel-actions/references/listener.md @@ -78,4 +78,4 @@ Event::assertDispatched(TaxiRequested::class); ## References -- https://www.laravelactions.com/2.x/as-listener.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-listener.html diff --git a/.claude/skills/laravel-actions/references/object.md b/.claude/skills/laravel-actions/references/object.md index 6a90be4d5..27ff6bd54 100644 --- a/.claude/skills/laravel-actions/references/object.md +++ b/.claude/skills/laravel-actions/references/object.md @@ -115,4 +115,4 @@ final class ArticleService 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 index 97766e6ce..eedb9f506 100644 --- a/.claude/skills/laravel-actions/references/testing-fakes.md +++ b/.claude/skills/laravel-actions/references/testing-fakes.md @@ -157,4 +157,4 @@ it('does not run sync when integration is disabled', function () { ## References -- https://www.laravelactions.com/2.x/as-fake.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-fake.html diff --git a/.claude/skills/laravel-actions/references/troubleshooting.md b/.claude/skills/laravel-actions/references/troubleshooting.md index cf6a5800f..d94114ff0 100644 --- a/.claude/skills/laravel-actions/references/troubleshooting.md +++ b/.claude/skills/laravel-actions/references/troubleshooting.md @@ -30,4 +30,4 @@ Use this reference when action wiring behaves unexpectedly. - 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 +- Isolate dependencies with fakes/spies where appropriate. diff --git a/.claude/skills/laravel-actions/references/with-attributes.md b/.claude/skills/laravel-actions/references/with-attributes.md index 1b28cf2cb..f9a234394 100644 --- a/.claude/skills/laravel-actions/references/with-attributes.md +++ b/.claude/skills/laravel-actions/references/with-attributes.md @@ -186,4 +186,4 @@ $article = $action->handle($validated); ## References -- https://www.laravelactions.com/2.x/with-attributes.html \ No newline at end of file +- https://www.laravelactions.com/2.x/with-attributes.html diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md index 99018f3ae..965e267e1 100644 --- a/.claude/skills/laravel-best-practices/SKILL.md +++ b/.claude/skills/laravel-best-practices/SKILL.md @@ -94,7 +94,7 @@ Check sibling files, related controllers, models, or tests for established patte ### 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 +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release - 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 @@ -187,4 +187,4 @@ 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 +3. Verify API syntax with `search-docs` for the installed Laravel version diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md index 920714a14..f12876e4c 100644 --- a/.claude/skills/laravel-best-practices/rules/advanced-queries.md +++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md @@ -103,4 +103,4 @@ public function scopeOrderByLastLogin($query): void ->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 index 165056422..51c6e65dc 100644 --- a/.claude/skills/laravel-best-practices/rules/architecture.md +++ b/.claude/skills/laravel-best-practices/rules/architecture.md @@ -82,7 +82,7 @@ $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. +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. Incorrect: ```php @@ -199,4 +199,4 @@ class Customer extends Model 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 index c6f8aaf1e..5f0b3a1e3 100644 --- a/.claude/skills/laravel-best-practices/rules/blade-views.md +++ b/.claude/skills/laravel-best-practices/rules/blade-views.md @@ -33,4 +33,4 @@ return view('dashboard', compact('users')) ## Use `@aware` for Deeply Nested Component Props -Avoids re-passing parent props through every level of nested components. \ No newline at end of file +Avoids re-passing parent props through every level of nested components. diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md index eb3ef3e62..67408d6e1 100644 --- a/.claude/skills/laravel-best-practices/rules/caching.md +++ b/.claude/skills/laravel-best-practices/rules/caching.md @@ -2,7 +2,7 @@ ## Use `Cache::remember()` Instead of Manual Get/Put -Atomic pattern prevents race conditions and removes boilerplate. +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. Incorrect: ```php @@ -67,4 +67,4 @@ 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 index 14f683d32..18e8d9e1d 100644 --- a/.claude/skills/laravel-best-practices/rules/collections.md +++ b/.claude/skills/laravel-best-practices/rules/collections.md @@ -41,4 +41,4 @@ 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 index 8fd8f536f..9bea727b3 100644 --- a/.claude/skills/laravel-best-practices/rules/config.md +++ b/.claude/skills/laravel-best-practices/rules/config.md @@ -2,7 +2,7 @@ ## `env()` Only in Config Files -Direct `env()` calls return `null` when config is cached. +Direct `env()` calls may return `null` when config is cached. Incorrect: ```php @@ -70,4 +70,4 @@ If the application already uses language files for localization, use `__()` for ```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 index 8fb719377..c49ba164e 100644 --- a/.claude/skills/laravel-best-practices/rules/db-performance.md +++ b/.claude/skills/laravel-best-practices/rules/db-performance.md @@ -189,4 +189,4 @@ return view('users.index', compact('users')); @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 index 09cd66a05..413d5da42 100644 --- a/.claude/skills/laravel-best-practices/rules/eloquent.md +++ b/.claude/skills/laravel-best-practices/rules/eloquent.md @@ -145,4 +145,4 @@ 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 +**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. diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md index bb8e7a387..4b1486676 100644 --- a/.claude/skills/laravel-best-practices/rules/error-handling.md +++ b/.claude/skills/laravel-best-practices/rules/error-handling.md @@ -69,4 +69,4 @@ class InvalidOrderException extends Exception 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 index bc43f1997..82e329e8f 100644 --- a/.claude/skills/laravel-best-practices/rules/events-notifications.md +++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md @@ -29,7 +29,11 @@ class InvoicePaid extends Notification implements ShouldQueue ## Use `afterCommit()` on Notifications in Transactions -Same race condition as events — the queued notification job may run before the transaction commits. +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` ## Route Notification Channels to Dedicated Queues @@ -45,4 +49,4 @@ 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 +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md index 0a7876ed3..8e2f16e8a 100644 --- a/.claude/skills/laravel-best-practices/rules/http-client.md +++ b/.claude/skills/laravel-best-practices/rules/http-client.md @@ -52,7 +52,7 @@ $response = Http::retry([100, 500, 1000]) Only retry on specific errors: ```php -$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { return $exception instanceof ConnectionException || ($exception instanceof RequestException && $exception->response->serverError()); })->post('https://api.example.com/data'); @@ -157,4 +157,4 @@ Test failure scenarios too: 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 index c7f67966e..7c717336d 100644 --- a/.claude/skills/laravel-best-practices/rules/mail.md +++ b/.claude/skills/laravel-best-practices/rules/mail.md @@ -10,7 +10,7 @@ A queued mailable dispatched inside a transaction may process before the commit. ## Use `assertQueued()` Not `assertSent()` for Queued Mailables -`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. @@ -24,4 +24,4 @@ Markdown mailables auto-generate both HTML and plain-text versions, use responsi 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 +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md index de25aa39c..df6f5f33c 100644 --- a/.claude/skills/laravel-best-practices/rules/migrations.md +++ b/.claude/skills/laravel-best-practices/rules/migrations.md @@ -118,4 +118,4 @@ 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 index d4575aac0..c41915e2b 100644 --- a/.claude/skills/laravel-best-practices/rules/queue-jobs.md +++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md @@ -106,25 +106,23 @@ When using time-based retry limits, set `$tries = 0` to avoid premature failure. ```php public $tries = 0; -public function retryUntil(): DateTime +public function retryUntil(): \DateTimeInterface { return now()->addHours(4); } ``` -## Use `WithoutOverlapping::untilProcessing()` +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release -Prevents concurrent execution while allowing new instances to queue. +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. ```php -public function middleware(): array +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing { - return [new WithoutOverlapping($this->product->id)->untilProcessing()]; + // Lock releases when processing begins, not when it finishes } ``` -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. @@ -143,4 +141,4 @@ Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or ], ], ], -``` \ 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 index e288375d7..b6e30864f 100644 --- a/.claude/skills/laravel-best-practices/rules/routing.md +++ b/.claude/skills/laravel-best-practices/rules/routing.md @@ -36,7 +36,8 @@ Use `Route::resource()` or `apiResource()` for RESTful endpoints. ```php Route::resource('posts', PostController::class); -Route::apiResource('api/posts', Api\PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); ``` ## Keep Controllers Thin @@ -95,4 +96,4 @@ public function store(StorePostRequest $request): RedirectResponse 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 index dfaefa26f..a98479450 100644 --- a/.claude/skills/laravel-best-practices/rules/scheduling.md +++ b/.claude/skills/laravel-best-practices/rules/scheduling.md @@ -36,4 +36,4 @@ Schedule::daily() 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 index 524d47e61..2d7200c29 100644 --- a/.claude/skills/laravel-best-practices/rules/security.md +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -32,7 +32,7 @@ Use policies or gates in controllers. Never skip authorization. Incorrect: ```php -public function update(Request $request, Post $post) +public function update(UpdatePostRequest $request, Post $post) { $post->update($request->validated()); } @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. Incorrect: ```blade @@ -121,7 +121,7 @@ Route::post('/login', LoginController::class)->middleware('throttle:login'); ## Validate File Uploads -Validate MIME type, extension, and size. Never trust client-provided filenames. +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. ```php public function rules(): array @@ -195,4 +195,4 @@ class Integration extends Model ]; } } -``` \ 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 index db689bf77..64d173081 100644 Binary files a/.claude/skills/laravel-best-practices/rules/style.md 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 index d39cc3ed0..4fbf12f8a 100644 --- a/.claude/skills/laravel-best-practices/rules/testing.md +++ b/.claude/skills/laravel-best-practices/rules/testing.md @@ -2,7 +2,7 @@ ## 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. +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. ## Use Model Assertions Over Raw Database Assertions @@ -40,4 +40,4 @@ Without `recycle()`, nested factories create separate instances of the same conc 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 index a20202ff1..5fde1064a 100644 --- a/.claude/skills/laravel-best-practices/rules/validation.md +++ b/.claude/skills/laravel-best-practices/rules/validation.md @@ -72,4 +72,4 @@ public function after(): array }, ]; } -``` \ No newline at end of file +``` diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md index 70ecd57d4..7a86d368e 100644 --- a/.claude/skills/livewire-development/SKILL.md +++ b/.claude/skills/livewire-development/SKILL.md @@ -112,4 +112,4 @@ $this->get('/posts/create') - Forgetting `wire:key` in loops causes unexpected behavior when items change - Using `wire:model` expecting real-time updates (use `wire:model.live` instead in v3) - Not validating/authorizing in Livewire actions (treat them like HTTP requests) -- Including Alpine.js separately when it's already bundled with Livewire 3 \ No newline at end of file +- Including Alpine.js separately when it's already bundled with Livewire 3 diff --git a/.claude/skills/mcp-development/SKILL.md b/.claude/skills/mcp-development/SKILL.md new file mode 100644 index 000000000..dcfb13004 --- /dev/null +++ b/.claude/skills/mcp-development/SKILL.md @@ -0,0 +1,96 @@ +--- +name: mcp-development +description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP." +license: MIT +metadata: + author: laravel +--- + +# MCP Development + +## Documentation + +Use `search-docs` for detailed Laravel MCP patterns and documentation. + +## Basic Usage + +Register MCP servers in `routes/ai.php`: + + +```php +use Laravel\Mcp\Facades\Mcp; + +Mcp::web(); +``` + +### Creating MCP Primitives + +Create MCP tools, resources, prompts, and servers using artisan commands: + +```bash +php artisan make:mcp-tool ToolName # Create a tool + +php artisan make:mcp-resource ResourceName # Create a resource + +php artisan make:mcp-prompt PromptName # Create a prompt + +php artisan make:mcp-server ServerName # Create a server + +``` + +After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties. + +### Tools + + +```php +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Request; +use Laravel\Mcp\Server\Response; + +class MyTool extends Tool +{ + public function handle(Request $request): Response + { + return new Response(['result' => 'success']); + } +} +``` + +### Registering Primitives in a Server + +Each MCP server must explicitly declare the tools, resources, and prompts it exposes. + + +```php +use Laravel\Mcp\Server; + +class AppServer extends Server +{ + protected array $tools = [ + \App\Mcp\Tools\MyTool::class, + ]; + + protected array $resources = [ + \App\Mcp\Resources\MyResource::class, + ]; + + protected array $prompts = [ + \App\Mcp\Prompts\MyPrompt::class, + ]; +} +``` + +## Verification + +1. Check `routes/ai.php` for proper registration +2. Test tool via MCP client + +## Common Pitfalls + +- Running `mcp:start` command (it hangs waiting for input) +- Using HTTPS locally with Node-based MCP clients +- Not using `search-docs` for the latest MCP documentation +- Not registering MCP server routes in `routes/ai.php` +- Do not register `ai.php` in `bootstrap.php`; it is registered automatically. +- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md index ba774e71b..ab2716165 100644 --- a/.claude/skills/pest-testing/SKILL.md +++ b/.claude/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -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." +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: test()/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 @@ -18,6 +18,12 @@ Use `search-docs` for detailed Pest 4 patterns and documentation. All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + ### Test Organization - Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. @@ -26,6 +32,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { @@ -154,4 +162,5 @@ arch('controllers') - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.claude/skills/socialite-development/SKILL.md b/.claude/skills/socialite-development/SKILL.md index e660da691..562e417af 100644 --- a/.claude/skills/socialite-development/SKILL.md +++ b/.claude/skills/socialite-development/SKILL.md @@ -77,4 +77,4 @@ Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use - 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 +- `user()` throws when the user declines authorization. Always handle denied grants. diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md index 7c8e295e8..c0cb2fbcd 100644 --- a/.claude/skills/tailwindcss-development/SKILL.md +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -116,4 +116,4 @@ If existing pages and components support dark mode, new pages and components mus - Using `@tailwind` directives instead of `@import "tailwindcss"` - Trying to use `tailwind.config.js` instead of CSS `@theme` directive - Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.cursor/skills/configure-nightwatch/SKILL.md b/.cursor/skills/configure-nightwatch/SKILL.md new file mode 100644 index 000000000..1fc580bbd --- /dev/null +++ b/.cursor/skills/configure-nightwatch/SKILL.md @@ -0,0 +1,404 @@ +--- +name: configure-nightwatch +description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads. +license: MIT +metadata: + author: laravel +--- + +# Nightwatch Configuration Guide + +This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types. + +## Documentation Reference + +The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of: + +- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction +- Individual event type pages with specific configuration options: + - [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture + - [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction + - [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction + - [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern + - [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling + - [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering + - [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel + - [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling + - [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering +- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist + +## Data Collection Flow + +Nightwatch processes events through three stages: + +1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks) +2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.) +3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information + +``` +Request/Command/Scheduled Task + | + v + [Sampling?] ----NO----> Drop entire trace + | YES + v + Events generated + | + v + [Filtering?] ----YES---> Drop specific event + | NO + v + [Redaction] ----------> Store modified data +``` + +--- + +## Sampling Configuration + +Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured. + +### Global Sample Rates + +Configure via environment variables: + +```bash + +# Default: 100% sampling (all requests/commands captured) + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +``` + +**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs. + +### Route-Based Sampling + +Apply different rates to specific routes using the `Sample` middleware: + +```php routes/web.php +use Illuminate\Support\Facades\Route; +use Laravel\Nightwatch\Http\Middleware\Sample; + +// Sample admin routes at 100% +Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () { + // All admin routes sampled fully +}); + +// Sample API routes at 5% +Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () { + // API routes sampled sparingly +}); + +// Always sample critical endpoints +Route::post('/checkout', [CheckoutController::class, 'process']) + ->middleware(Sample::always()); + +// Never sample health checks +Route::get('/health', [HealthController::class, 'check']) + ->middleware(Sample::never()); +``` + +### Unmatched Route Sampling + +Handle 404/bot traffic with reduced sampling: + +```php routes/web.php +Route::fallback(fn () => abort(404)) + ->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes +``` + +### Dynamic Sampling + +Sample based on runtime conditions (user role, request attributes): + +```php app/Http/Middleware/SampleAdminRequests.php +use Closure; +use Illuminate\Http\Request; +use Laravel\Nightwatch\Facades\Nightwatch; + +class SampleAdminRequests +{ + public function handle(Request $request, Closure $next) + { + if ($request->user()?->isAdmin()) { + Nightwatch::sample(); // Always sample admin requests + } + return $next($request); + } +} +``` + +### Command Sampling + +Exclude specific commands from sampling: + +```php AppServiceProvider.php +use Illuminate\Console\Events\CommandStarting; +use Illuminate\Support\Facades\Event; +use Laravel\Nightwatch\Facades\Nightwatch; + +public function boot(): void +{ + Event::listen(function (CommandStarting $event) { + if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) { + Nightwatch::dontSample(); + } + }); +} +``` + +### Vendor Commands + +Nightwatch automatically ignores framework/internal commands. Opt-in to capture them: + +```php +Nightwatch::captureDefaultVendorCommands(); +``` + +--- + +## Filtering Configuration + +Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage. + +### Database Queries + +**Filter all queries** (disable query collection): + +```bash +NIGHTWATCH_IGNORE_QUERIES=true +``` + +**Filter specific queries** by SQL pattern: + +```php AppServiceProvider.php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Query; + +public function boot(): void +{ + // Filter job table queries (PostgreSQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'into "jobs"'); + }); + + // Filter cache table queries (MySQL) + Nightwatch::rejectQueries(function (Query $query) { + return str_contains($query->sql, 'from `cache`') + || str_contains($query->sql, 'into `cache`'); + }); +} +``` + +### Cache Events + +**Filter all cache events**: + +```bash +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +``` + +**Filter by cache key patterns**: + +```php +Nightwatch::rejectCacheKeys([ + 'my-app:users', // Exact match + '/^my-app:posts:/', // Regex: starts with my-app:posts: + '/^[a-zA-Z0-9]{40}$/', // Regex: session IDs +]); +``` + +**Filter with callback**: + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) { + return str_starts_with($cacheEvent->key, 'temp:'); +}); +``` + +### Mail Events + +**Filter all mail**: + +```bash +NIGHTWATCH_IGNORE_MAIL=true +``` + +**Filter specific mail**: + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::rejectMail(function (Mail $mail) { + return str_contains($mail->subject, 'Newsletter'); +}); +``` + +### Notification Events + +**Filter all notifications**: + +```bash +NIGHTWATCH_IGNORE_NOTIFICATIONS=true +``` + +**Filter by channel**: + +```php +use Laravel\Nightwatch\Records\Notification; + +Nightwatch::rejectNotifications(function (Notification $notification) { + return $notification->channel === 'database'; +}); +``` + +### Outgoing HTTP Requests + +**Filter all outgoing requests**: + +```bash +NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true +``` + +**Filter by URL**: + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) { + return str_contains($request->url, 'analytics.example.com'); +}); +``` + +### Queued Jobs + +**Filter specific jobs**: + +```php +use Laravel\Nightwatch\Records\QueuedJob; + +Nightwatch::rejectQueuedJobs(function (QueuedJob $job) { + return $job->name === 'App\Jobs\LowPriorityJob'; +}); +``` + +### Decoupling Job Sampling + +Sample jobs independently from parent contexts: + +```php +use Illuminate\Support\Facades\Queue; + +public function boot(): void +{ + Queue::before(fn () => Nightwatch::sample(rate: 0.5)); +} +``` + +--- + +## Redaction Configuration + +Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content. + +### Request Redaction + +**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN): + +```bash + +# Customize redacted headers + +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key +``` + +**Redact request payloads** (disabled by default): + +```bash + +# Enable payload capture + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true + +# Customize redacted fields + +NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card +``` + +**Programmatic redaction**: + +```php +use Laravel\Nightwatch\Facades\Nightwatch; +use Laravel\Nightwatch\Records\Request; + +Nightwatch::redactRequests(function (Request $request) { + $request->url = str_replace('secret', '***', $request->url); + $request->ip = preg_replace('/\d+$/', '***', $request->ip); +}); +``` + +### Query Redaction + +```php +use Laravel\Nightwatch\Records\Query; + +Nightwatch::redactQueries(function (Query $query) { + $query->sql = str_replace('secret_token', '***', $query->sql); +}); +``` + +### Cache Redaction + +```php +use Laravel\Nightwatch\Records\CacheEvent; + +Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) { + $cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key); +}); +``` + +### Command Redaction + +```php +use Laravel\Nightwatch\Records\Command; + +Nightwatch::redactCommands(function (Command $command) { + $command->command = preg_replace('/--password=\S+/', '--password=***', $command->command); +}); +``` + +### Exception Redaction + +```php +use Laravel\Nightwatch\Records\Exception; + +Nightwatch::redactExceptions(function (Exception $exception) { + $exception->message = str_replace('secret', '***', $exception->message); +}); +``` + +### Mail Redaction + +```php +use Laravel\Nightwatch\Records\Mail; + +Nightwatch::redactMail(function (Mail $mail) { + $mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject); +}); +``` + +### Outgoing Request Redaction + +```php +use Laravel\Nightwatch\Records\OutgoingRequest; + +Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) { + $outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url); +}); +``` diff --git a/.cursor/skills/configure-nightwatch/reference.md b/.cursor/skills/configure-nightwatch/reference.md new file mode 100644 index 000000000..b071d2f2e --- /dev/null +++ b/.cursor/skills/configure-nightwatch/reference.md @@ -0,0 +1,108 @@ +# Nightwatch Configuration Reference + +## Configuration Summary by Event Type + +| Event Type | Sampling | Filtering | Redaction | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- | +| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP | +| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments | +| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement | +| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key | +| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable | +| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject | +| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable | +| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL | +| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message | + +--- + +## Production Recommendations + +### High-Traffic Applications + +```bash + +# Conservative sampling + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests + +NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands + +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions + +# Filter noisy events + +NIGHTWATCH_IGNORE_CACHE_EVENTS=true +NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically + +``` + +### Privacy-Conscious Applications + +```bash + +# Disable sensitive data collection + +NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false +NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN + +# Or use redaction in AppServiceProvider + +``` + +### Balanced Configuration (Recommended Start) + +```bash + +# Sample rates + +NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 +NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 +NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 + +# Filter obvious noise programmatically + +# Redact PII as needed + +``` + +--- + +## Verification Checklist + +After configuration: + +- [ ] Sampling rates appropriate for traffic volume +- [ ] Noisy events filtered (cache, certain queries) +- [ ] Sensitive data redacted (PII, tokens, credentials) +- [ ] Exceptions always captured for debugging +- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0` +- [ ] Monitor event quota usage in Nightwatch dashboard + +--- + +## Common Patterns + +### Filter Health Checks + Reduce Sampling + +```php +Route::get('/health', fn() => ['status' => 'ok']) + ->middleware(Sample::never()); +``` + +### Exclude Internal/Vendor Queries + +```php +Nightwatch::rejectQueries(fn($q) => + str_contains($q->sql, 'telescope') || + str_contains($q->sql, 'pulse') +); +``` + +### Protect User Data in Cache Keys + +```php +Nightwatch::redactCacheEvents(fn($e) => + $e->key = preg_replace('/user:\d+/', 'user:***', $e->key) +); +``` diff --git a/.cursor/skills/configuring-horizon/SKILL.md b/.cursor/skills/configuring-horizon/SKILL.md index bed1e74c0..68477acd2 100644 --- a/.cursor/skills/configuring-horizon/SKILL.md +++ b/.cursor/skills/configuring-horizon/SKILL.md @@ -82,4 +82,4 @@ protected function gate(): void - 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 +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. diff --git a/.cursor/skills/configuring-horizon/references/metrics.md b/.cursor/skills/configuring-horizon/references/metrics.md index 312f79ee7..7e1aea6bb 100644 --- a/.cursor/skills/configuring-horizon/references/metrics.md +++ b/.cursor/skills/configuring-horizon/references/metrics.md @@ -18,4 +18,4 @@ A single manual run populates the dashboard momentarily but will not keep it upd ### `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 +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. diff --git a/.cursor/skills/configuring-horizon/references/notifications.md b/.cursor/skills/configuring-horizon/references/notifications.md index 943d1a26a..d6d3feed9 100644 --- a/.cursor/skills/configuring-horizon/references/notifications.md +++ b/.cursor/skills/configuring-horizon/references/notifications.md @@ -18,4 +18,4 @@ Configure notifications in the `boot()` method of `App\Providers\HorizonServiceP ### 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 +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. diff --git a/.cursor/skills/configuring-horizon/references/supervisors.md b/.cursor/skills/configuring-horizon/references/supervisors.md index 9da0c1769..b71285cfd 100644 --- a/.cursor/skills/configuring-horizon/references/supervisors.md +++ b/.cursor/skills/configuring-horizon/references/supervisors.md @@ -24,4 +24,4 @@ Auto-balancing suits variable load, but if a queue should always have exactly N ### 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 +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. diff --git a/.cursor/skills/configuring-horizon/references/tags.md b/.cursor/skills/configuring-horizon/references/tags.md index 263c955c1..8234e4adb 100644 --- a/.cursor/skills/configuring-horizon/references/tags.md +++ b/.cursor/skills/configuring-horizon/references/tags.md @@ -18,4 +18,4 @@ Adding a job class to the `silenced` array in `config/horizon.php` removes it fr ### `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 +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. diff --git a/.cursor/skills/debugging-output-and-previewing-html-using-ray/SKILL.md b/.cursor/skills/debugging-output-and-previewing-html-using-ray/SKILL.md index 4583bd56e..eecbb3662 100644 --- a/.cursor/skills/debugging-output-and-previewing-html-using-ray/SKILL.md +++ b/.cursor/skills/debugging-output-and-previewing-html-using-ray/SKILL.md @@ -411,4 +411,4 @@ curl -X POST http://localhost:23517/ \ | `remove` | (empty) | Remove entry | | `confetti` | (empty) | Confetti animation | | `show_app` | (empty) | Show Ray window | -| `hide_app` | (empty) | Hide Ray window | \ No newline at end of file +| `hide_app` | (empty) | Hide Ray window | diff --git a/.cursor/skills/fortify-development/SKILL.md b/.cursor/skills/fortify-development/SKILL.md index 86322d9c0..2c4e84fe4 100644 --- a/.cursor/skills/fortify-development/SKILL.md +++ b/.cursor/skills/fortify-development/SKILL.md @@ -1,6 +1,6 @@ --- 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.' +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), passkeys, 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, passkeys, WebAuthn, 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 @@ -32,6 +32,7 @@ Enable in `config/fortify.php` features array: - `Features::updateProfileInformation()` - Profile updates - `Features::updatePasswords()` - Password changes - `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes +- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys > Use `search-docs` for feature configuration options and customization patterns. @@ -50,6 +51,18 @@ Enable in `config/fortify.php` features array: > Use `search-docs` for TOTP implementation and recovery code handling patterns. +### Passkeys Setup + +``` +- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser +- [ ] Enable passkeys feature in config/fortify.php +- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate +- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable +- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion +``` + +> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm. + ### Email Verification Setup ``` @@ -128,4 +141,11 @@ Configure via `fortify.limiters.login` in config. Default configuration throttle | Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | | 2FA Challenge | POST | `/two-factor-challenge` | | Get QR Code | GET | `/user/two-factor-qr-code` | -| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | +| Passkey Login Options | GET | `/passkeys/login/options` | +| Passkey Login | POST | `/passkeys/login` | +| Passkey Confirm Options| GET | `/passkeys/confirm/options` | +| Passkey Confirm | POST | `/passkeys/confirm` | +| Passkey Options | GET | `/user/passkeys/options` | +| Register Passkey | POST | `/user/passkeys` | +| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | diff --git a/.cursor/skills/laravel-actions/SKILL.md b/.cursor/skills/laravel-actions/SKILL.md index 862dd55b5..297475c0f 100644 --- a/.cursor/skills/laravel-actions/SKILL.md +++ b/.cursor/skills/laravel-actions/SKILL.md @@ -299,4 +299,4 @@ Use these references for deep dives by entrypoint/topic. Keep `SKILL.md` focused - 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 +- Troubleshooting: `references/troubleshooting.md` diff --git a/.cursor/skills/laravel-actions/references/command.md b/.cursor/skills/laravel-actions/references/command.md index a7b255daf..3fbe31c39 100644 --- a/.cursor/skills/laravel-actions/references/command.md +++ b/.cursor/skills/laravel-actions/references/command.md @@ -157,4 +157,4 @@ $this->artisan('users:update-role 1 admin') ## References -- https://www.laravelactions.com/2.x/as-command.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-command.html diff --git a/.cursor/skills/laravel-actions/references/controller.md b/.cursor/skills/laravel-actions/references/controller.md index d48c34df8..9f3e88906 100644 --- a/.cursor/skills/laravel-actions/references/controller.md +++ b/.cursor/skills/laravel-actions/references/controller.md @@ -336,4 +336,4 @@ public function getAuthorizationFailure(): void ## References -- https://www.laravelactions.com/2.x/as-controller.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-controller.html diff --git a/.cursor/skills/laravel-actions/references/job.md b/.cursor/skills/laravel-actions/references/job.md index b4c7cbea0..8954b4c94 100644 --- a/.cursor/skills/laravel-actions/references/job.md +++ b/.cursor/skills/laravel-actions/references/job.md @@ -422,4 +422,4 @@ public function jobFailed(?Throwable $e, ...$parameters): void ## References -- https://www.laravelactions.com/2.x/as-job.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-job.html diff --git a/.cursor/skills/laravel-actions/references/listener.md b/.cursor/skills/laravel-actions/references/listener.md index c5233001d..dad01ab92 100644 --- a/.cursor/skills/laravel-actions/references/listener.md +++ b/.cursor/skills/laravel-actions/references/listener.md @@ -78,4 +78,4 @@ Event::assertDispatched(TaxiRequested::class); ## References -- https://www.laravelactions.com/2.x/as-listener.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-listener.html diff --git a/.cursor/skills/laravel-actions/references/object.md b/.cursor/skills/laravel-actions/references/object.md index 6a90be4d5..27ff6bd54 100644 --- a/.cursor/skills/laravel-actions/references/object.md +++ b/.cursor/skills/laravel-actions/references/object.md @@ -115,4 +115,4 @@ final class ArticleService 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 index 97766e6ce..eedb9f506 100644 --- a/.cursor/skills/laravel-actions/references/testing-fakes.md +++ b/.cursor/skills/laravel-actions/references/testing-fakes.md @@ -157,4 +157,4 @@ it('does not run sync when integration is disabled', function () { ## References -- https://www.laravelactions.com/2.x/as-fake.html \ No newline at end of file +- https://www.laravelactions.com/2.x/as-fake.html diff --git a/.cursor/skills/laravel-actions/references/troubleshooting.md b/.cursor/skills/laravel-actions/references/troubleshooting.md index cf6a5800f..d94114ff0 100644 --- a/.cursor/skills/laravel-actions/references/troubleshooting.md +++ b/.cursor/skills/laravel-actions/references/troubleshooting.md @@ -30,4 +30,4 @@ Use this reference when action wiring behaves unexpectedly. - 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 +- Isolate dependencies with fakes/spies where appropriate. diff --git a/.cursor/skills/laravel-actions/references/with-attributes.md b/.cursor/skills/laravel-actions/references/with-attributes.md index 1b28cf2cb..f9a234394 100644 --- a/.cursor/skills/laravel-actions/references/with-attributes.md +++ b/.cursor/skills/laravel-actions/references/with-attributes.md @@ -186,4 +186,4 @@ $article = $action->handle($validated); ## References -- https://www.laravelactions.com/2.x/with-attributes.html \ No newline at end of file +- https://www.laravelactions.com/2.x/with-attributes.html diff --git a/.cursor/skills/laravel-best-practices/SKILL.md b/.cursor/skills/laravel-best-practices/SKILL.md index 99018f3ae..965e267e1 100644 --- a/.cursor/skills/laravel-best-practices/SKILL.md +++ b/.cursor/skills/laravel-best-practices/SKILL.md @@ -94,7 +94,7 @@ Check sibling files, related controllers, models, or tests for established patte ### 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 +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release - 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 @@ -187,4 +187,4 @@ 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 +3. Verify API syntax with `search-docs` for the installed Laravel version diff --git a/.cursor/skills/laravel-best-practices/rules/advanced-queries.md b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md index 920714a14..f12876e4c 100644 --- a/.cursor/skills/laravel-best-practices/rules/advanced-queries.md +++ b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md @@ -103,4 +103,4 @@ public function scopeOrderByLastLogin($query): void ->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 index 165056422..51c6e65dc 100644 --- a/.cursor/skills/laravel-best-practices/rules/architecture.md +++ b/.cursor/skills/laravel-best-practices/rules/architecture.md @@ -82,7 +82,7 @@ $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. +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. Incorrect: ```php @@ -199,4 +199,4 @@ class Customer extends Model 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 index c6f8aaf1e..5f0b3a1e3 100644 --- a/.cursor/skills/laravel-best-practices/rules/blade-views.md +++ b/.cursor/skills/laravel-best-practices/rules/blade-views.md @@ -33,4 +33,4 @@ return view('dashboard', compact('users')) ## Use `@aware` for Deeply Nested Component Props -Avoids re-passing parent props through every level of nested components. \ No newline at end of file +Avoids re-passing parent props through every level of nested components. diff --git a/.cursor/skills/laravel-best-practices/rules/caching.md b/.cursor/skills/laravel-best-practices/rules/caching.md index eb3ef3e62..67408d6e1 100644 --- a/.cursor/skills/laravel-best-practices/rules/caching.md +++ b/.cursor/skills/laravel-best-practices/rules/caching.md @@ -2,7 +2,7 @@ ## Use `Cache::remember()` Instead of Manual Get/Put -Atomic pattern prevents race conditions and removes boilerplate. +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. Incorrect: ```php @@ -67,4 +67,4 @@ 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 index 14f683d32..18e8d9e1d 100644 --- a/.cursor/skills/laravel-best-practices/rules/collections.md +++ b/.cursor/skills/laravel-best-practices/rules/collections.md @@ -41,4 +41,4 @@ 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 index 8fd8f536f..9bea727b3 100644 --- a/.cursor/skills/laravel-best-practices/rules/config.md +++ b/.cursor/skills/laravel-best-practices/rules/config.md @@ -2,7 +2,7 @@ ## `env()` Only in Config Files -Direct `env()` calls return `null` when config is cached. +Direct `env()` calls may return `null` when config is cached. Incorrect: ```php @@ -70,4 +70,4 @@ If the application already uses language files for localization, use `__()` for ```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 index 8fb719377..c49ba164e 100644 --- a/.cursor/skills/laravel-best-practices/rules/db-performance.md +++ b/.cursor/skills/laravel-best-practices/rules/db-performance.md @@ -189,4 +189,4 @@ return view('users.index', compact('users')); @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 index 09cd66a05..413d5da42 100644 --- a/.cursor/skills/laravel-best-practices/rules/eloquent.md +++ b/.cursor/skills/laravel-best-practices/rules/eloquent.md @@ -145,4 +145,4 @@ 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 +**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. diff --git a/.cursor/skills/laravel-best-practices/rules/error-handling.md b/.cursor/skills/laravel-best-practices/rules/error-handling.md index bb8e7a387..4b1486676 100644 --- a/.cursor/skills/laravel-best-practices/rules/error-handling.md +++ b/.cursor/skills/laravel-best-practices/rules/error-handling.md @@ -69,4 +69,4 @@ class InvalidOrderException extends Exception 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 index bc43f1997..82e329e8f 100644 --- a/.cursor/skills/laravel-best-practices/rules/events-notifications.md +++ b/.cursor/skills/laravel-best-practices/rules/events-notifications.md @@ -29,7 +29,11 @@ class InvoicePaid extends Notification implements ShouldQueue ## Use `afterCommit()` on Notifications in Transactions -Same race condition as events — the queued notification job may run before the transaction commits. +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` ## Route Notification Channels to Dedicated Queues @@ -45,4 +49,4 @@ 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 +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. diff --git a/.cursor/skills/laravel-best-practices/rules/http-client.md b/.cursor/skills/laravel-best-practices/rules/http-client.md index 0a7876ed3..8e2f16e8a 100644 --- a/.cursor/skills/laravel-best-practices/rules/http-client.md +++ b/.cursor/skills/laravel-best-practices/rules/http-client.md @@ -52,7 +52,7 @@ $response = Http::retry([100, 500, 1000]) Only retry on specific errors: ```php -$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { return $exception instanceof ConnectionException || ($exception instanceof RequestException && $exception->response->serverError()); })->post('https://api.example.com/data'); @@ -157,4 +157,4 @@ Test failure scenarios too: 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 index c7f67966e..7c717336d 100644 --- a/.cursor/skills/laravel-best-practices/rules/mail.md +++ b/.cursor/skills/laravel-best-practices/rules/mail.md @@ -10,7 +10,7 @@ A queued mailable dispatched inside a transaction may process before the commit. ## Use `assertQueued()` Not `assertSent()` for Queued Mailables -`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. @@ -24,4 +24,4 @@ Markdown mailables auto-generate both HTML and plain-text versions, use responsi 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 +Don't mix them — it conflates concerns and makes tests brittle. diff --git a/.cursor/skills/laravel-best-practices/rules/migrations.md b/.cursor/skills/laravel-best-practices/rules/migrations.md index de25aa39c..df6f5f33c 100644 --- a/.cursor/skills/laravel-best-practices/rules/migrations.md +++ b/.cursor/skills/laravel-best-practices/rules/migrations.md @@ -118,4 +118,4 @@ 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 index d4575aac0..c41915e2b 100644 --- a/.cursor/skills/laravel-best-practices/rules/queue-jobs.md +++ b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md @@ -106,25 +106,23 @@ When using time-based retry limits, set `$tries = 0` to avoid premature failure. ```php public $tries = 0; -public function retryUntil(): DateTime +public function retryUntil(): \DateTimeInterface { return now()->addHours(4); } ``` -## Use `WithoutOverlapping::untilProcessing()` +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release -Prevents concurrent execution while allowing new instances to queue. +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. ```php -public function middleware(): array +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing { - return [new WithoutOverlapping($this->product->id)->untilProcessing()]; + // Lock releases when processing begins, not when it finishes } ``` -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. @@ -143,4 +141,4 @@ Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or ], ], ], -``` \ 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 index e288375d7..b6e30864f 100644 --- a/.cursor/skills/laravel-best-practices/rules/routing.md +++ b/.cursor/skills/laravel-best-practices/rules/routing.md @@ -36,7 +36,8 @@ Use `Route::resource()` or `apiResource()` for RESTful endpoints. ```php Route::resource('posts', PostController::class); -Route::apiResource('api/posts', Api\PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); ``` ## Keep Controllers Thin @@ -95,4 +96,4 @@ public function store(StorePostRequest $request): RedirectResponse 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 index dfaefa26f..a98479450 100644 --- a/.cursor/skills/laravel-best-practices/rules/scheduling.md +++ b/.cursor/skills/laravel-best-practices/rules/scheduling.md @@ -36,4 +36,4 @@ Schedule::daily() 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 index 524d47e61..2d7200c29 100644 --- a/.cursor/skills/laravel-best-practices/rules/security.md +++ b/.cursor/skills/laravel-best-practices/rules/security.md @@ -32,7 +32,7 @@ Use policies or gates in controllers. Never skip authorization. Incorrect: ```php -public function update(Request $request, Post $post) +public function update(UpdatePostRequest $request, Post $post) { $post->update($request->validated()); } @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. Incorrect: ```blade @@ -121,7 +121,7 @@ Route::post('/login', LoginController::class)->middleware('throttle:login'); ## Validate File Uploads -Validate MIME type, extension, and size. Never trust client-provided filenames. +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. ```php public function rules(): array @@ -195,4 +195,4 @@ class Integration extends Model ]; } } -``` \ 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 index db689bf77..64d173081 100644 Binary files a/.cursor/skills/laravel-best-practices/rules/style.md 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 index d39cc3ed0..4fbf12f8a 100644 --- a/.cursor/skills/laravel-best-practices/rules/testing.md +++ b/.cursor/skills/laravel-best-practices/rules/testing.md @@ -2,7 +2,7 @@ ## 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. +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. ## Use Model Assertions Over Raw Database Assertions @@ -40,4 +40,4 @@ Without `recycle()`, nested factories create separate instances of the same conc 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 index a20202ff1..5fde1064a 100644 --- a/.cursor/skills/laravel-best-practices/rules/validation.md +++ b/.cursor/skills/laravel-best-practices/rules/validation.md @@ -72,4 +72,4 @@ public function after(): array }, ]; } -``` \ No newline at end of file +``` diff --git a/.cursor/skills/livewire-development/SKILL.md b/.cursor/skills/livewire-development/SKILL.md index 70ecd57d4..7a86d368e 100644 --- a/.cursor/skills/livewire-development/SKILL.md +++ b/.cursor/skills/livewire-development/SKILL.md @@ -112,4 +112,4 @@ $this->get('/posts/create') - Forgetting `wire:key` in loops causes unexpected behavior when items change - Using `wire:model` expecting real-time updates (use `wire:model.live` instead in v3) - Not validating/authorizing in Livewire actions (treat them like HTTP requests) -- Including Alpine.js separately when it's already bundled with Livewire 3 \ No newline at end of file +- Including Alpine.js separately when it's already bundled with Livewire 3 diff --git a/.cursor/skills/mcp-development/SKILL.md b/.cursor/skills/mcp-development/SKILL.md new file mode 100644 index 000000000..dcfb13004 --- /dev/null +++ b/.cursor/skills/mcp-development/SKILL.md @@ -0,0 +1,96 @@ +--- +name: mcp-development +description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP." +license: MIT +metadata: + author: laravel +--- + +# MCP Development + +## Documentation + +Use `search-docs` for detailed Laravel MCP patterns and documentation. + +## Basic Usage + +Register MCP servers in `routes/ai.php`: + + +```php +use Laravel\Mcp\Facades\Mcp; + +Mcp::web(); +``` + +### Creating MCP Primitives + +Create MCP tools, resources, prompts, and servers using artisan commands: + +```bash +php artisan make:mcp-tool ToolName # Create a tool + +php artisan make:mcp-resource ResourceName # Create a resource + +php artisan make:mcp-prompt PromptName # Create a prompt + +php artisan make:mcp-server ServerName # Create a server + +``` + +After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties. + +### Tools + + +```php +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Request; +use Laravel\Mcp\Server\Response; + +class MyTool extends Tool +{ + public function handle(Request $request): Response + { + return new Response(['result' => 'success']); + } +} +``` + +### Registering Primitives in a Server + +Each MCP server must explicitly declare the tools, resources, and prompts it exposes. + + +```php +use Laravel\Mcp\Server; + +class AppServer extends Server +{ + protected array $tools = [ + \App\Mcp\Tools\MyTool::class, + ]; + + protected array $resources = [ + \App\Mcp\Resources\MyResource::class, + ]; + + protected array $prompts = [ + \App\Mcp\Prompts\MyPrompt::class, + ]; +} +``` + +## Verification + +1. Check `routes/ai.php` for proper registration +2. Test tool via MCP client + +## Common Pitfalls + +- Running `mcp:start` command (it hangs waiting for input) +- Using HTTPS locally with Node-based MCP clients +- Not using `search-docs` for the latest MCP documentation +- Not registering MCP server routes in `routes/ai.php` +- Do not register `ai.php` in `bootstrap.php`; it is registered automatically. +- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config diff --git a/.cursor/skills/pest-testing/SKILL.md b/.cursor/skills/pest-testing/SKILL.md index ba774e71b..ab2716165 100644 --- a/.cursor/skills/pest-testing/SKILL.md +++ b/.cursor/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -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." +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: test()/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 @@ -18,6 +18,12 @@ Use `search-docs` for detailed Pest 4 patterns and documentation. All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +The `{name}` argument should include only the path and test name, but should not include the test suite. +- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php` +- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php` +- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php` +- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php` + ### Test Organization - Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. @@ -26,6 +32,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { @@ -154,4 +162,5 @@ arch('controllers') - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test` diff --git a/.cursor/skills/socialite-development/SKILL.md b/.cursor/skills/socialite-development/SKILL.md index e660da691..562e417af 100644 --- a/.cursor/skills/socialite-development/SKILL.md +++ b/.cursor/skills/socialite-development/SKILL.md @@ -77,4 +77,4 @@ Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use - 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 +- `user()` throws when the user declines authorization. Always handle denied grants. diff --git a/.cursor/skills/tailwindcss-development/SKILL.md b/.cursor/skills/tailwindcss-development/SKILL.md index 7c8e295e8..c0cb2fbcd 100644 --- a/.cursor/skills/tailwindcss-development/SKILL.md +++ b/.cursor/skills/tailwindcss-development/SKILL.md @@ -116,4 +116,4 @@ If existing pages and components support dark mode, new pages and components mus - Using `@tailwind` directives instead of `@import "tailwindcss"` - Trying to use `tailwind.config.js` instead of CSS `@theme` directive - Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml index 1d94bec81..821fef177 100644 --- a/.github/workflows/chore-pr-comments.yml +++ b/.github/workflows/chore-pr-comments.yml @@ -40,7 +40,10 @@ jobs: # This will help ensure that our documentation remains accurate and up-to-date for all users. steps: - name: Add comment - if: github.event.label.name == matrix.label + if: >- + (github.event.label.name == matrix.label || github.event.label.name == '📑 Waiting for Docs PR') + && contains(github.event.pull_request.labels.*.name, matrix.label) + && contains(github.event.pull_request.labels.*.name, '📑 Waiting for Docs PR') run: gh pr comment "$NUMBER" --body "$BODY" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 2c403efe8..b4faa103a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,147 @@ +# AGENTS.md + +This file provides guidance to agentic coding tools when working with code in this repository. + +## Project Overview + +Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4. + ## Design Reference For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo. +## Development Environment + +Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio. + +```bash +# Start dev environment (uses docker-compose.dev.yml) +spin up # or: docker compose -f docker-compose.dev.yml up -d +spin down # stop services +``` + +The app runs at `localhost:8000` by default. Vite dev server on port 5173. + +## Common Commands + +```bash +# Tests (Pest 4) +php artisan test --compact # all tests +php artisan test --compact --filter=testName # single test +php artisan test --compact tests/Feature/SomeTest.php # specific file + +# Code formatting (Pint, Laravel preset) +vendor/bin/pint --dirty --format agent # format changed files + +# Frontend +npm run dev # vite dev server +npm run build # production build +``` + +## Browser Tests (Pest Browser Plugin) + +Uses `pestphp/pest-plugin-browser` with Laravel Dusk 8. New browser tests go in `tests/v4/Browser/`. + +```bash +# Run all browser tests +php artisan test --compact tests/v4/Browser/ + +# Run a specific browser test file +php artisan test --compact tests/v4/Browser/LoginTest.php + +# Run a specific test by name +php artisan test --compact --filter='can login with valid credentials' +``` + +### Writing Browser Tests + +- Place new tests in `tests/v4/Browser/` — legacy Dusk tests in `tests/Browser/` should not be used as reference. +- Use `RefreshDatabase` and seed required data (at minimum `InstanceSettings::create(['id' => 0])`) in `beforeEach`. +- Key API: `visit()`, `fill(field, value)`, `click(text)`, `assertSee()`, `assertDontSee()`, `assertPathIs()`, `screenshot()`. +- Always call `screenshot()` at the end of each test for debugging. +- For authenticated tests, create a helper function that logs in via the UI: + +```php +function loginAsRoot(): mixed +{ + return visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login'); +} +``` + +- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. +- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`). +- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. + +## Architecture + +### Backend Structure (app/) +- **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. +- **Application** — A deployed app (from Git or Docker image) with environment variables, previews, deployment queue. +- **Service** — A pre-configured service stack from templates (`templates/service-templates-latest.json`). +- **Standalone Databases** — Individual database instances (Postgres, MySQL, MariaDB, MongoDB, Redis, Clickhouse, KeyDB, Dragonfly). +- **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources. +- **Proxy** — Traefik reverse proxy managed per server. + +### Frontend +- Livewire 3 components with Alpine.js for client-side interactivity +- Blade templates in `resources/views/livewire/` +- Tailwind CSS v4 with `@tailwindcss/forms` and `@tailwindcss/typography` +- Vite for asset bundling + +### Laravel 10 Structure (NOT Laravel 11+ slim structure) +- 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/` + +## Key Conventions + +- Use `php artisan make:*` commands with `--no-interaction` to create files +- Use Eloquent relationships, avoid `DB::` facade — prefer `Model::query()` +- PHP 8.5: constructor property promotion, explicit return types, type hints +- 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. 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 + +- Main branch: `v4.x` +- Development branch: `next` +- PRs should target `v4.x` + === foundation rules === @@ -17,6 +157,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/horizon (HORIZON) - v5 +- laravel/mcp (MCP) - v0 - laravel/nightwatch (NIGHTWATCH) - v1 - laravel/pail (PAIL) - v1 - laravel/prompts (PROMPTS) - v0 @@ -25,29 +166,16 @@ This application is a Laravel application and its main Laravel ecosystems packag - livewire/livewire (LIVEWIRE) - v3 - laravel/boost (BOOST) - v2 - laravel/dusk (DUSK) - v8 -- laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 - laravel/telescope (TELESCOPE) - v5 - pestphp/pest (PEST) - v4 - phpunit/phpunit (PHPUNIT) - v12 - rector/rector (RECTOR) - v2 -- laravel-echo (ECHO) - v2 - tailwindcss (TAILWINDCSS) - v4 -- vue (VUE) - v3 ## 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. - -- `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. +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. ## Conventions @@ -107,7 +235,6 @@ This project has domain-specific skills available. You MUST activate the relevan - 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. ## Tinker @@ -122,10 +249,16 @@ This project has domain-specific skills available. You MUST activate the relevan - Always use curly braces for control structures, even for single-line bodies. - 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`. +- Follow existing application Enum naming conventions. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. - Use array shape type definitions in PHPDoc blocks. +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + === tests rules === # Test Enforcement @@ -209,6 +342,7 @@ This project has domain-specific skills available. You MUST activate the relevan ## Pest - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Do NOT delete tests without approval. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 188889954..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,318 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4. - -## Design Reference - -For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo. - -## Development Environment - -Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio. - -```bash -# Start dev environment (uses docker-compose.dev.yml) -spin up # or: docker compose -f docker-compose.dev.yml up -d -spin down # stop services -``` - -The app runs at `localhost:8000` by default. Vite dev server on port 5173. - -## Common Commands - -```bash -# Tests (Pest 4) -php artisan test --compact # all tests -php artisan test --compact --filter=testName # single test -php artisan test --compact tests/Feature/SomeTest.php # specific file - -# Code formatting (Pint, Laravel preset) -vendor/bin/pint --dirty --format agent # format changed files - -# Frontend -npm run dev # vite dev server -npm run build # production build -``` - -## Architecture - -### Backend Structure (app/) -- **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. -- **Application** — A deployed app (from Git or Docker image) with environment variables, previews, deployment queue. -- **Service** — A pre-configured service stack from templates (`templates/service-templates-latest.json`). -- **Standalone Databases** — Individual database instances (Postgres, MySQL, MariaDB, MongoDB, Redis, Clickhouse, KeyDB, Dragonfly). -- **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources. -- **Proxy** — Traefik reverse proxy managed per server. - -### Frontend -- Livewire 3 components with Alpine.js for client-side interactivity -- Blade templates in `resources/views/livewire/` -- Tailwind CSS v4 with `@tailwindcss/forms` and `@tailwindcss/typography` -- Vite for asset bundling - -### Laravel 10 Structure (NOT Laravel 11+ slim structure) -- 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/` - -## 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 -- 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. 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 - -- Main branch: `v4.x` -- Development branch: `next` -- PRs should target `v4.x` - - -=== foundation rules === - -# Laravel Boost Guidelines - -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. - -## Foundational Context - -This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. - -- php - 8.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 -- laravel/telescope (TELESCOPE) - v5 -- pestphp/pest (PEST) - v4 -- phpunit/phpunit (PHPUNIT) - v12 -- rector/rector (RECTOR) - v2 -- laravel-echo (ECHO) - v2 -- tailwindcss (TAILWINDCSS) - v4 -- vue (VUE) - v3 - -## 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. - -- `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 - -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. -- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. -- Check for existing components to reuse before writing a new one. - -## Verification Scripts - -- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. - -## Application Structure & Architecture - -- Stick to existing directory structure; don't create new base folders without approval. -- Do not change the application's dependencies without approval. - -## Frontend Bundling - -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. - -## Documentation Files - -- You must only create documentation files if explicitly requested by the user. - -## Replies - -- Be concise in your explanations - focus on what's important rather than explaining obvious details. - -=== boost rules === - -# Laravel Boost - -## 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 - -- 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. - -## Tinker - -- 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. -- 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 === - -# Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - -=== laravel/core rules === - -# Do Things the Laravel Way - -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `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. - -### Model Creation - -- 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 - -- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - -## URL Generation - -- When generating links to other pages, prefer named routes and the `route()` function. - -## Testing - -- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. -- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - -## Vite Error - -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. - -=== laravel/v12 rules === - -# Laravel 12 - -- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. -- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. -- This is perfectly fine and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it. - -## Laravel 10 Structure - -- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. -- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: - - Middleware registration happens in `app/Http/Kernel.php` - - Exception handling is in `app/Exceptions/Handler.php` - - Console commands and schedule register in `app/Console/Kernel.php` - - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` - -## Database - -- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - -### Models - -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - -=== livewire/core rules === - -# Livewire - -- 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 - -- 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 === - -## 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. - - diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php index 71b6ed52b..47aa5e2e9 100644 --- a/app/Actions/Stripe/CancelSubscription.php +++ b/app/Actions/Stripe/CancelSubscription.php @@ -5,6 +5,7 @@ namespace App\Actions\Stripe; use App\Models\Subscription; use App\Models\User; use Illuminate\Support\Collection; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class CancelSubscription @@ -21,7 +22,7 @@ class CancelSubscription $this->isDryRun = $isDryRun; if (! $isDryRun && isCloud()) { - $this->stripe = new StripeClient(config('subscription.stripe_api_key')); + $this->stripe = app(StripeClient::class); } } @@ -64,7 +65,7 @@ class CancelSubscription ]; } - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $subscriptions = $this->getSubscriptionsPreview(); $verified = collect(); @@ -88,7 +89,7 @@ class CancelSubscription 'reason' => "Status in Stripe: {$stripeSubscription->status}", ]); } - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { // Subscription doesn't exist in Stripe $notFound->push([ 'subscription' => $subscription, @@ -181,7 +182,7 @@ class CancelSubscription return false; } - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripe->subscriptions->cancel($subscriptionId, []); // Update local record if exists diff --git a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php index 34c7d194a..bb5d02103 100644 --- a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php +++ b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php @@ -3,6 +3,7 @@ namespace App\Actions\Stripe; use App\Models\Team; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class CancelSubscriptionAtPeriodEnd @@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd public function __construct(?StripeClient $stripe = null) { - $this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + $this->stripe = $stripe ?? app(StripeClient::class); } /** @@ -47,7 +48,7 @@ class CancelSubscriptionAtPeriodEnd \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) { + } catch (InvalidRequestException $e) { \Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index b10d783db..f891a252f 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -3,6 +3,8 @@ namespace App\Actions\Stripe; use App\Models\Team; +use Carbon\Carbon; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class RefundSubscription @@ -13,7 +15,7 @@ class RefundSubscription public function __construct(?StripeClient $stripe = null) { - $this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + $this->stripe = $stripe ?? app(StripeClient::class); } /** @@ -39,7 +41,7 @@ class RefundSubscription try { $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { return $this->ineligible('Subscription not found in Stripe.'); } @@ -49,7 +51,7 @@ class RefundSubscription return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd); } - $startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); + $startDate = Carbon::createFromTimestamp($stripeSubscription->start_date); $daysSinceStart = (int) $startDate->diffInDays(now()); $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; @@ -130,7 +132,7 @@ class RefundSubscription \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}"); return ['success' => true, 'error' => null]; - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Actions/Stripe/ResumeSubscription.php b/app/Actions/Stripe/ResumeSubscription.php index d8019def7..87b114d56 100644 --- a/app/Actions/Stripe/ResumeSubscription.php +++ b/app/Actions/Stripe/ResumeSubscription.php @@ -3,6 +3,7 @@ namespace App\Actions\Stripe; use App\Models\Team; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class ResumeSubscription @@ -11,7 +12,7 @@ class ResumeSubscription public function __construct(?StripeClient $stripe = null) { - $this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + $this->stripe = $stripe ?? app(StripeClient::class); } /** @@ -43,7 +44,7 @@ class ResumeSubscription \Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}"); return ['success' => true, 'error' => null]; - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { \Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index d4d29af20..458a91d55 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity public function __construct(?StripeClient $stripe = null) { - $this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + $this->stripe = $stripe ?? app(StripeClient::class); } /** diff --git a/app/Console/Commands/Cloud/CloudFixSubscription.php b/app/Console/Commands/Cloud/CloudFixSubscription.php index 194e9bb5f..55012c006 100644 --- a/app/Console/Commands/Cloud/CloudFixSubscription.php +++ b/app/Console/Commands/Cloud/CloudFixSubscription.php @@ -4,6 +4,8 @@ namespace App\Console\Commands\Cloud; use App\Models\Team; use Illuminate\Console\Command; +use Stripe\Exception\InvalidRequestException; +use Stripe\StripeClient; class CloudFixSubscription extends Command { @@ -31,7 +33,7 @@ class CloudFixSubscription extends Command */ public function handle() { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); if ($this->option('verify-all')) { return $this->verifyAllActiveSubscriptions($stripe); @@ -111,7 +113,7 @@ class CloudFixSubscription extends Command /** * Fix canceled subscriptions in the database */ - private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) + private function fixCanceledSubscriptions(StripeClient $stripe) { $isDryRun = $this->option('dry-run'); $checkOne = $this->option('one'); @@ -220,7 +222,7 @@ class CloudFixSubscription extends Command break; } } - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { if ($e->getStripeCode() === 'resource_missing') { $toFixCount++; @@ -326,7 +328,7 @@ class CloudFixSubscription extends Command /** * Verify all active subscriptions against Stripe API */ - private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) + private function verifyAllActiveSubscriptions(StripeClient $stripe) { $isDryRun = $this->option('dry-run'); $shouldFix = $this->option('fix-verified'); @@ -570,7 +572,7 @@ class CloudFixSubscription extends Command break; } - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { $this->error(' → Error: '.$e->getMessage()); if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) { @@ -730,7 +732,7 @@ class CloudFixSubscription extends Command /** * Search for subscriptions by customer ID */ - private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false) + private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false) { try { $subscriptions = $stripe->subscriptions->all([ @@ -770,7 +772,7 @@ class CloudFixSubscription extends Command /** * Search for subscriptions by team member emails */ - private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails) + private function searchSubscriptionsByEmails(StripeClient $stripe, $emails) { $this->line(' → Searching by team member emails...'); diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php index 43ba06804..02be98fc9 100644 --- a/app/Console/Commands/Emails.php +++ b/app/Console/Commands/Emails.php @@ -18,6 +18,7 @@ use Exception; use Illuminate\Console\Command; use Illuminate\Mail\Message; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Str; use Mail; use function Laravel\Prompts\confirm; diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php index e316fc391..2978feb3c 100644 --- a/app/Console/Commands/Generate/Services.php +++ b/app/Console/Commands/Generate/Services.php @@ -4,6 +4,7 @@ namespace App\Console\Commands\Generate; use Illuminate\Console\Command; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Process; use Symfony\Component\Yaml\Yaml; class Services extends Command @@ -77,6 +78,7 @@ class Services extends Command 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -99,6 +101,26 @@ class Services extends Command return $payload; } + private function templateLastUpdatedAt(string $file): ?string + { + $process = Process::path(base_path())->run([ + 'git', + 'log', + '-1', + '--format=%cI', + '--', + "templates/compose/{$file}", + ]); + + if ($process->failed()) { + return null; + } + + $timestamp = trim($process->output()); + + return $timestamp === '' ? null : $timestamp; + } + private function generateServiceTemplatesWithFqdn(): void { $serviceTemplatesWithFqdn = collect(array_merge( @@ -155,6 +177,7 @@ class Services extends Command 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -232,6 +255,7 @@ class Services extends Command 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 5e5405a7a..bdb6f8d90 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -30,7 +30,6 @@ use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; class ApplicationsController extends Controller { @@ -59,6 +58,10 @@ class ApplicationsController extends Controller ]); } + if ($application->is_shown_once ?? false) { + $application->makeHidden(['value', 'real_value']); + } + return serializeApiResponse($application); } @@ -1193,7 +1196,7 @@ class ApplicationsController extends Controller $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1432,7 +1435,7 @@ class ApplicationsController extends Controller $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1641,7 +1644,7 @@ class ApplicationsController extends Controller $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1687,7 +1690,7 @@ class ApplicationsController extends Controller ], 422); } if (! $request->has('name')) { - $request->offsetSet('name', 'dockerfile-'.new Cuid2); + $request->offsetSet('name', 'dockerfile-'.new_public_id()); } $return = $this->validateDataApplications($request, $server); @@ -1761,7 +1764,7 @@ class ApplicationsController extends Controller $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1805,7 +1808,7 @@ class ApplicationsController extends Controller ], 422); } if (! $request->has('name')) { - $request->offsetSet('name', 'docker-image-'.new Cuid2); + $request->offsetSet('name', 'docker-image-'.new_public_id()); } $return = $this->validateDataApplications($request, $server); if ($return instanceof JsonResponse) { @@ -1880,7 +1883,7 @@ class ApplicationsController extends Controller $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -2678,7 +2681,7 @@ class ApplicationsController extends Controller ]); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -2873,8 +2876,12 @@ class ApplicationsController extends Controller $this->authorize('manageEnvironment', $application); + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + $validator = customApiValidator($request->all(), [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_preview' => 'boolean', 'is_literal' => 'boolean', @@ -3097,12 +3104,18 @@ class ApplicationsController extends Controller ], 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', 'comment']); + $item = collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']); + + if ($item->has('key')) { + $item->put('key', ValidationPatterns::normalizeEnvironmentVariableKey((string) $item->get('key'))); + } + + return $item; }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { $validator = customApiValidator($item, [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_preview' => 'boolean', 'is_literal' => 'boolean', @@ -3299,8 +3312,12 @@ class ApplicationsController extends Controller $this->authorize('manageEnvironment', $application); + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + $validator = customApiValidator($request->all(), [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_preview' => 'boolean', 'is_literal' => 'boolean', @@ -3585,7 +3602,7 @@ class ApplicationsController extends Controller $this->authorize('deploy', $application); - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -3783,7 +3800,7 @@ class ApplicationsController extends Controller $this->authorize('deploy', $application); - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index d652f2ba1..ad6eeb982 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -177,6 +177,7 @@ class CloudProviderTokensController extends Controller if (is_null($token)) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('view', $token); return response()->json($this->removeSensitiveData($token)); } @@ -243,6 +244,7 @@ class CloudProviderTokensController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [CloudProviderToken::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { @@ -394,6 +396,7 @@ class CloudProviderTokensController extends Controller if (! $token) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('update', $token); $token->update(array_intersect_key($body, array_flip($allowedFields))); @@ -475,6 +478,7 @@ class CloudProviderTokensController extends Controller if (! $token) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('delete', $token); if ($token->hasServers()) { return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); @@ -545,9 +549,18 @@ class CloudProviderTokensController extends Controller if (! $cloudToken) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('view', $cloudToken); $validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token); + auditLog('api.cloud_token.validated', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $cloudToken->uuid, + 'cloud_token_name' => $cloudToken->name, + 'provider' => $cloudToken->provider, + 'valid' => $validation['valid'], + ]); + return response()->json([ 'valid' => $validation['valid'], 'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'], diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index bceef4d39..9a463e8d3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3133,8 +3133,12 @@ class DatabasesController extends Controller $this->authorize('manageEnvironment', $database); + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + $validator = customApiValidator($request->all(), [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', @@ -3281,8 +3285,12 @@ class DatabasesController extends Controller $updatedEnvs = collect(); foreach ($bulk_data as $item) { + if (array_key_exists('key', $item)) { + $item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']); + } + $validator = customApiValidator($item, [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', @@ -3399,8 +3407,12 @@ class DatabasesController extends Controller $this->authorize('manageEnvironment', $database); + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + $validator = customApiValidator($request->all(), [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c93731d68..f0cf48efa 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -15,7 +15,6 @@ use App\Models\Tag; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; use OpenApi\Attributes as OA; -use Visus\Cuid2\Cuid2; class DeployController extends Controller { @@ -511,7 +510,7 @@ class DeployController extends Controller if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') { return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null]; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $resource, deployment_uuid: $deployment_uuid, diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 651969b97..150743f99 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -183,6 +183,7 @@ class GithubController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [GithubApp::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; @@ -564,6 +565,7 @@ class GithubController extends Controller $githubApp = GithubApp::where('id', $github_app_id) ->where('team_id', $teamId) ->firstOrFail(); + $this->authorize('update', $githubApp); // Define allowed fields for update $allowedFields = [ @@ -737,6 +739,7 @@ class GithubController extends Controller $githubApp = GithubApp::where('id', $github_app_id) ->where('team_id', $teamId) ->firstOrFail(); + $this->authorize('delete', $githubApp); // Check if the GitHub app is being used by any applications if ($githubApp->applications->isNotEmpty()) { diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2f35ba576..1c9d6f9ef 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -116,6 +116,7 @@ class HetznerController extends Controller if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -237,6 +238,7 @@ class HetznerController extends Controller if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -336,6 +338,7 @@ class HetznerController extends Controller if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -445,6 +448,7 @@ class HetznerController extends Controller if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -550,6 +554,7 @@ class HetznerController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [Server::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { @@ -620,6 +625,7 @@ class HetznerController extends Controller if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); // Validate private key $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 0e5f6e93b..92f19c7ae 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -97,6 +97,7 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); $project->load(['environments']); @@ -233,6 +234,7 @@ class ProjectController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [Project::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { @@ -385,6 +387,7 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('update', $project); $project->update($request->only($allowedFields)); @@ -469,6 +472,7 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('delete', $project); if (! $project->isEmpty()) { return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } @@ -652,6 +656,7 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('update', $project); $existingEnvironment = $project->environments()->where('name', $request->name)->first(); if ($existingEnvironment) { @@ -746,6 +751,7 @@ class ProjectController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('delete', $environment); if (! $environment->isEmpty()) { return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e59c40866..0559bcd99 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -110,6 +110,7 @@ class SecurityController extends Controller 'message' => 'Private Key not found.', ], 404); } + $this->authorize('view', $key); return response()->json($this->removeSensitiveData($key)); } @@ -176,6 +177,7 @@ class SecurityController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [PrivateKey::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; @@ -338,6 +340,7 @@ class SecurityController extends Controller 'message' => 'Private Key not found.', ], 404); } + $this->authorize('update', $foundKey); $foundKey->update($request->only($allowedFields)); auditLog('api.private_key.updated', [ @@ -421,6 +424,7 @@ class SecurityController extends Controller if (is_null($key)) { return response()->json(['message' => 'Private Key not found.'], 404); } + $this->authorize('delete', $key); if ($key->isInUse()) { return response()->json([ diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index df5c60d40..3af05f4fa 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -97,12 +97,12 @@ class SentinelController extends Controller if ($this->shouldDispatchUpdate($server, $data)) { PushServerUpdateJob::dispatch($server, $data); - } - auditLog('sentinel.metrics_pushed', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + } return response()->json(['message' => 'ok'], 200); } diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index e43026a72..f4efc8577 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -148,6 +148,7 @@ class ServersController extends Controller if (is_null($server)) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('view', $server); if ($with_resources) { $server['resources'] = $server->definedResources()->map(function ($resource) { $payload = [ @@ -477,6 +478,7 @@ class ServersController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [ModelsServer::class]); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { @@ -701,6 +703,7 @@ class ServersController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('update', $server); if ($request->proxy_type) { $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { return str($proxyType->value)->lower(); @@ -825,6 +828,7 @@ class ServersController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('delete', $server); $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN); @@ -924,6 +928,7 @@ class ServersController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('update', $server); ValidateServer::dispatch($server); auditLog('api.server.validated', [ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 11a23d46c..32137b866 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -39,6 +39,10 @@ class ServicesController extends Controller ]); } + if ($service->is_shown_once ?? false) { + $service->makeHidden(['value', 'real_value']); + } + return serializeApiResponse($service); } @@ -1247,8 +1251,12 @@ class ServicesController extends Controller $this->authorize('manageEnvironment', $service); + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + $validator = customApiValidator($request->all(), [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', @@ -1396,8 +1404,12 @@ class ServicesController extends Controller $updatedEnvs = collect(); foreach ($bulk_data as $item) { + if (array_key_exists('key', $item)) { + $item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']); + } + $validator = customApiValidator($item, [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', @@ -1515,8 +1527,12 @@ class ServicesController extends Controller $this->authorize('manageEnvironment', $service); + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + $validator = customApiValidator($request->all(), [ - 'key' => 'string|required', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'string|nullable', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index 03b36e4e0..d47399533 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -110,6 +110,7 @@ class TeamController extends Controller if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } + $this->authorize('view', $team); $team = $this->removeSensitiveData($team); return response()->json( @@ -168,6 +169,7 @@ class TeamController extends Controller if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } + $this->authorize('view', $team); $members = $team->members; $members->makeHidden([ 'pivot', diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index d09435fd8..4d4a5a080 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Support\DatabaseBackupFileValidator; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Controller as BaseController; @@ -12,6 +13,8 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver; class UploadController extends BaseController { + use AuthorizesRequests; + private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS; @@ -24,6 +27,8 @@ class UploadController extends BaseController return response()->json(['error' => 'You do not have permission for this database'], 500); } + $this->authorize('uploadBackup', $resource); + $chunk = $request->file('file'); $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null; if (blank($originalName) || ! self::hasAllowedExtension($originalName)) { diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index d37ba7cee..435f5efab 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -10,7 +10,6 @@ use App\Models\Application; use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Visus\Cuid2\Cuid2; class Bitbucket extends Controller { @@ -141,7 +140,7 @@ class Bitbucket extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -192,7 +191,7 @@ class Bitbucket extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index be064e380..82a8cc8af 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -11,7 +11,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class Gitea extends Controller { @@ -127,7 +126,7 @@ class Gitea extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -194,7 +193,7 @@ class Gitea extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 40c5cbdf0..28e92dcd4 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -17,7 +17,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class Github extends Controller { @@ -144,7 +143,7 @@ class Github extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -262,6 +261,16 @@ class Github extends Controller return response('Nothing to do. No GitHub App found.'); } $webhook_secret = data_get($github_app, 'webhook_secret'); + if (empty($webhook_secret)) { + auditLogWebhookFailure('github', 'webhook_secret_missing', [ + 'mode' => 'app', + 'github_app_id' => $github_app->id, + 'github_app_name' => $github_app->name, + 'installation_target_id' => $x_github_hook_installation_target_id, + ]); + + return response('Invalid signature.'); + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (config('app.env') !== 'local') { if (! hash_equals($x_hub_signature_256, $hmac)) { @@ -362,7 +371,7 @@ class Github extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 231a0b6e5..c90f4ad40 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -11,7 +11,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class Gitlab extends Controller { @@ -168,7 +167,7 @@ class Gitlab extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -236,7 +235,7 @@ class Gitlab extends Controller continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index f81c7d184..8951ad50d 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -7,9 +7,34 @@ use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class ApiAbility extends CheckForAnyAbility { + /** + * Permissions that only admins/owners may use. + */ + private const MEMBER_DISALLOWED_ABILITIES = [ + 'root', + 'write', + 'write:sensitive', + 'deploy', + 'read:sensitive', + ]; + public function handle($request, $next, ...$abilities) { try { + $token = $request->user()->currentAccessToken(); + $teamId = data_get($token, 'team_id'); + + if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) { + $tokenAbilities = $token->abilities ?? []; + $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); + + if (! empty($disallowed)) { + return response()->json([ + 'message' => 'This API token has permissions ('.implode(', ', $disallowed).') that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.', + ], 403); + } + } + if ($request->user()->tokenCan('root')) { return $next($request); } diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 49584ddb3..8d7c51d11 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -10,10 +10,13 @@ class ApiSensitiveData public function handle(Request $request, Closure $next) { $token = $request->user()->currentAccessToken(); + $hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); + $teamId = (int) data_get($token, 'team_id'); + $isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; - // Allow access to sensitive data if token has root or read:sensitive permission + // Allow access to sensitive data only if token has permission AND user is admin/owner $request->attributes->add([ - 'can_read_sensitive' => $token->can('root') || $token->can('read:sensitive'), + 'can_read_sensitive' => $hasTokenPermission && $isAdmin, ]); return $next($request); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1b8ef3fc4..81c662cac 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -37,7 +37,6 @@ use JsonException; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; -use Visus\Cuid2\Cuid2; class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { @@ -2207,7 +2206,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); queue_application_deployment( deployment_uuid: $deployment_uuid, application: $this->application, @@ -2307,6 +2306,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ], [ executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), + 'hidden' => true, + 'skip_command_log' => true, ], [ executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"), @@ -2366,12 +2367,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id !== 0) { $this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head."); } - $this->execute_remote_command( - [ - $importCommands, - 'hidden' => true, - ] - ); + $this->execute_remote_command(...$this->gitCommandDefinitions($importCommands)); $this->create_workdir(); $this->execute_remote_command( [ @@ -2401,6 +2397,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $commands; } + private function gitCommandDefinitions(Collection|array|string $commands): array + { + if (is_string($commands)) { + return [ + [ + $commands, + 'hidden' => true, + ], + ]; + } + + return collect($commands) + ->map(function ($command): array { + if (is_string($command)) { + return [ + $command, + 'hidden' => true, + ]; + } + + if (is_array($command)) { + return $command + ['hidden' => true]; + } + + return [ + 'command' => $command, + 'hidden' => true, + ]; + }) + ->values() + ->all(); + } + private function cleanup_git() { $this->execute_remote_command( diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 64e900b49..79bf929be 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -27,7 +27,6 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Throwable; -use Visus\Cuid2\Cuid2; class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { @@ -309,7 +308,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // Generate unique UUID for each database backup execution $attempts = 0; do { - $this->backup_log_uuid = (string) new Cuid2; + $this->backup_log_uuid = new_public_id(); $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); $attempts++; if ($attempts >= 3 && $exists) { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 141351784..61fc3d4ee 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -14,7 +14,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Visus\Cuid2\Cuid2; +use Throwable; class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue { @@ -71,16 +71,25 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue ->first(); if ($found) { - ApplicationPullRequestUpdateJob::dispatchSync( - application: $application, - preview: $found, - status: ProcessStatus::CLOSED - ); - - CleanupPreviewDeployment::run($application, $this->pullRequestId, $found); + try { + $this->dispatchPullRequestClosedUpdate($application, $found); + } catch (Throwable $e) { + report($e); + } finally { + CleanupPreviewDeployment::run($application, $this->pullRequestId, $found); + } } } + protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void + { + ApplicationPullRequestUpdateJob::dispatchSync( + application: $application, + preview: $preview, + status: ProcessStatus::CLOSED + ); + } + private function handleOpenAction(Application $application, ?GithubApp $githubApp): void { if (! $application->isPRDeployable()) { @@ -156,7 +165,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue } // Queue the deployment - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); queue_application_deployment( application: $application, pull_request_id: $this->pullRequestId, diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index b031b9c7d..6ddbfe145 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -36,7 +36,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue $data = data_get($this->event, 'data.object'); switch ($type) { case 'radar.early_fraud_warning.created': - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $id = data_get($data, 'id'); $charge = data_get($data, 'charge'); if ($charge) { @@ -100,7 +100,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue if ($subscription->stripe_subscription_id) { try { - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id ); @@ -166,7 +166,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // Verify payment status with Stripe API before sending failure notification if ($paymentIntentId) { try { - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { @@ -260,7 +260,10 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue $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 = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT); + $quantity = max( + UpdateSubscriptionQuantity::MIN_SERVER_LIMIT, + min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT) + ); $team = data_get($subscription, 'team'); if ($team) { $team->update([ diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php index 927d50467..99b3f8be9 100755 --- a/app/Jobs/SubscriptionInvoiceFailedJob.php +++ b/app/Jobs/SubscriptionInvoiceFailedJob.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Stripe\StripeClient; class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue { @@ -27,7 +28,7 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue $subscription = $this->team->subscription; if ($subscription && $subscription->stripe_customer_id) { try { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); if ($subscription->stripe_subscription_id) { $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php index 0e221756d..572d6e78c 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Stripe\StripeClient; class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue { @@ -33,7 +34,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue ->where('stripe_invoice_paid', true) ->get(); - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); // Bulk fetch all valid subscription IDs from Stripe (active + past_due) $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); @@ -123,7 +124,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue * * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null */ - private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array + private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array { try { $customer = $stripe->customers->retrieve($customerId); @@ -177,7 +178,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue * * @return array */ - private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array + private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array { $validIds = []; $fetched = 0; diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index f7addacf1..35b41a369 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Stripe\StripeClient; class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue { @@ -29,7 +30,7 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu if (! $this->subscription->stripe_subscription_id && $this->subscription->stripe_customer_id) { try { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $subscriptions = $stripe->subscriptions->all([ 'customer' => $this->subscription->stripe_customer_id, 'limit' => 1, @@ -50,7 +51,7 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu } try { - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripeSubscription = $stripe->subscriptions->retrieve( $this->subscription->stripe_subscription_id ); diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index 4d22047cc..226d2e332 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -54,6 +54,9 @@ class Index extends Component public function getSubscribers() { + if (Auth::id() !== 0 && ! session('impersonating')) { + return redirect()->route('dashboard'); + } $this->inactiveSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', false)->count(); $this->activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->count(); } diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 2d0ae939d..5582efbda 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -9,13 +9,15 @@ use App\Models\Server; use App\Models\Team; use App\Services\ConfigurationRepository; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Url; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Index extends Component { + use AuthorizesRequests; + protected $listeners = [ 'refreshBoardingIndex' => 'validateServer', 'prerequisitesInstalled' => 'handlePrerequisitesInstalled', @@ -174,6 +176,9 @@ class Index extends Component public function skipBoarding() { + if (auth()->user()?->isMember()) { + return redirect()->route('dashboard'); + } Team::find(currentTeam()->id)->update([ 'show_boarding' => false, ]); @@ -276,6 +281,7 @@ class Index extends Component ]); try { + $this->authorize('create', PrivateKey::class); $privateKey = PrivateKey::createAndStore([ 'name' => $this->privateKeyName, 'description' => $this->privateKeyDescription, @@ -294,6 +300,12 @@ class Index extends Component { $this->validate(); + try { + $this->authorize('create', Server::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } + $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { @@ -457,7 +469,7 @@ class Index extends Component $this->createdProject = Project::create([ 'name' => 'My first project', 'team_id' => currentTeam()->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); $this->currentState = 'create-resource'; } diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 254823163..61e8bba34 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -9,7 +9,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Docker extends Component { @@ -35,7 +34,7 @@ class Docker extends Component public function mount(?string $server_id = null): void { - $this->network = (string) new Cuid2; + $this->network = new_public_id(); $this->servers = Server::isUsable()->get(); if (filled($server_id)) { @@ -68,7 +67,7 @@ class Docker extends Component public function generateName(): void { - $name = data_get($this->selectedServer, 'name', new Cuid2); + $name = data_get($this->selectedServer, 'name', new_public_id()); $this->name = str("{$name}-{$this->network}")->kebab(); } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 9d55d7462..1b344c905 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Destination; use App\Models\StandaloneDocker; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -31,8 +32,12 @@ class Show extends Component if (! $destination) { return redirect()->route('destination.index'); } + $this->authorize('view', $destination); + $this->destination = $destination; $this->syncData(); + } catch (AuthorizationException) { + abort(403); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index df2adf22b..4148764de 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1053,6 +1053,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new postgresql)', 'type' => 'postgresql', 'category' => 'Databases', + 'logo' => 'svgs/postgresql.svg', 'resourceType' => 'database', ]); @@ -1062,6 +1063,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new mysql)', 'type' => 'mysql', 'category' => 'Databases', + 'logo' => 'svgs/mysql.svg', 'resourceType' => 'database', ]); @@ -1071,6 +1073,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new mariadb)', 'type' => 'mariadb', 'category' => 'Databases', + 'logo' => 'svgs/mariadb.svg', 'resourceType' => 'database', ]); @@ -1080,6 +1083,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new redis)', 'type' => 'redis', 'category' => 'Databases', + 'logo' => 'svgs/redis.svg', 'resourceType' => 'database', ]); @@ -1089,6 +1093,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new keydb)', 'type' => 'keydb', 'category' => 'Databases', + 'logo' => 'svgs/keydb.svg', 'resourceType' => 'database', ]); @@ -1098,6 +1103,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new dragonfly)', 'type' => 'dragonfly', 'category' => 'Databases', + 'logo' => 'svgs/dragonfly.svg', 'resourceType' => 'database', ]); @@ -1107,6 +1113,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new mongodb)', 'type' => 'mongodb', 'category' => 'Databases', + 'logo' => 'svgs/mongodb.svg', 'resourceType' => 'database', ]); @@ -1116,6 +1123,7 @@ class GlobalSearch extends Component 'quickcommand' => '(type: new clickhouse)', 'type' => 'clickhouse', 'category' => 'Databases', + 'logo' => 'svgs/clickhouse-icon.svg', 'resourceType' => 'database', ]); } diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php index f660f9c13..cf476eb75 100644 --- a/app/Livewire/MonacoEditor.php +++ b/app/Livewire/MonacoEditor.php @@ -4,7 +4,6 @@ namespace App\Livewire; // use Livewire\Component; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class MonacoEditor extends Component { @@ -40,7 +39,7 @@ class MonacoEditor extends Component public function render() { if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); } if (is_null($this->name)) { diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index 8e5478b5e..52e4460ad 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,12 +2,16 @@ namespace App\Livewire; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Livewire\Component; class NavbarDeleteTeam extends Component { + use AuthorizesRequests; + public $team; public function mount() @@ -17,27 +21,35 @@ class NavbarDeleteTeam extends Component public function delete($password, $selectedActions = []) { - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; + try { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); + + $currentTeam->members->each(function ($user) use ($currentTeam) { + if ($user->id === Auth::id()) { + return; + } + $user->teams()->detach($currentTeam); + $session = DB::table('sessions')->where('user_id', $user->id)->first(); + if ($session) { + DB::table('sessions')->where('id', $session->id)->delete(); + } + }); + + Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); + $currentTeam->delete(); + + $newTeam = Auth::user()->teams()->first(); + refreshSession($newTeam); + + return redirect()->route('team.index'); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $currentTeam = currentTeam(); - $currentTeam->delete(); - - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - - refreshSession(); - - return redirectRoute($this, 'team.index'); } public function render() diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index ab3884320..59350a3e1 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -110,7 +110,9 @@ class Discord extends Component refreshSession(); } else { $this->discordEnabled = $this->settings->discord_enabled; - $this->discordWebhookUrl = $this->settings->discord_webhook_url; + $this->discordWebhookUrl = auth()->user()->can('update', $this->settings) + ? $this->settings->discord_webhook_url + : null; $this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications; $this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications; diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index d79eea87b..f894c5005 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -113,8 +113,13 @@ class Pushover extends Component refreshSession(); } else { $this->pushoverEnabled = $this->settings->pushover_enabled; - $this->pushoverUserKey = $this->settings->pushover_user_key; - $this->pushoverApiToken = $this->settings->pushover_api_token; + if (auth()->user()->can('update', $this->settings)) { + $this->pushoverUserKey = $this->settings->pushover_user_key; + $this->pushoverApiToken = $this->settings->pushover_api_token; + } else { + $this->pushoverUserKey = null; + $this->pushoverApiToken = null; + } $this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications; $this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications; diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index f870b3986..58cab5494 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -110,7 +110,9 @@ class Slack extends Component refreshSession(); } else { $this->slackEnabled = $this->settings->slack_enabled; - $this->slackWebhookUrl = $this->settings->slack_webhook_url; + $this->slackWebhookUrl = auth()->user()->can('update', $this->settings) + ? $this->settings->slack_webhook_url + : null; $this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications; $this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications; diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index fc3966cf6..78eb7ef9f 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -169,8 +169,13 @@ class Telegram extends Component $this->settings->save(); } else { $this->telegramEnabled = $this->settings->telegram_enabled; - $this->telegramToken = $this->settings->telegram_token; - $this->telegramChatId = $this->settings->telegram_chat_id; + if (auth()->user()->can('update', $this->settings)) { + $this->telegramToken = $this->settings->telegram_token; + $this->telegramChatId = $this->settings->telegram_chat_id; + } else { + $this->telegramToken = null; + $this->telegramChatId = null; + } $this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications; $this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications; diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index 630d422a9..4a67180ff 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -105,7 +105,9 @@ class Webhook extends Component refreshSession(); } else { $this->webhookEnabled = $this->settings->webhook_enabled; - $this->webhookUrl = $this->settings->webhook_url; + $this->webhookUrl = auth()->user()->can('update', $this->settings) + ? $this->settings->webhook_url + : null; $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 974f0608a..e004ac69e 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -4,11 +4,13 @@ namespace App\Livewire\Project; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class AddEmpty extends Component { + use AuthorizesRequests; + public string $name; public string $description = ''; @@ -29,12 +31,13 @@ class AddEmpty extends Component public function submit() { try { + $this->authorize('create', Project::class); $this->validate(); $project = Project::create([ 'name' => $this->name, 'description' => $this->description, 'team_id' => currentTeam()->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); $productionEnvironment = $project->environments()->where('name', 'production')->first(); diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index c9f818e2c..9ed0ab807 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -59,7 +59,9 @@ class Show extends Component $this->application_deployment_queue = $application_deployment_queue; $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->deployment_uuid = $deploymentUuid; - $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->is_debug_enabled = auth()->user()->isMember() + ? false + : $this->application->settings->is_debug_enabled; $this->isKeepAliveOn(); } @@ -110,6 +112,8 @@ class Show extends Component public function downloadAllLogs(): string { + $this->authorize('update', $this->application); + $logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true) ->map(function ($line) { $prefix = ''; diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index ebdc014ae..b60f543ba 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -6,11 +6,14 @@ use App\Enums\ApplicationDeploymentStatus; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Carbon; use Livewire\Component; class DeploymentNavbar extends Component { + use AuthorizesRequests; + public ApplicationDeploymentQueue $application_deployment_queue; public Application $application; @@ -25,7 +28,9 @@ class DeploymentNavbar extends Component { $this->application = Application::ownedByCurrentTeam()->find($this->application_deployment_queue->application_id); $this->server = $this->application->destination->server; - $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->is_debug_enabled = auth()->user()->isMember() + ? false + : $this->application->settings->is_debug_enabled; } public function deploymentFinished() @@ -35,15 +40,21 @@ class DeploymentNavbar extends Component public function show_debug() { - $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; - $this->application->settings->save(); - $this->is_debug_enabled = $this->application->settings->is_debug_enabled; - $this->dispatch('refreshQueue'); + try { + $this->authorize('update', $this->application); + $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; + $this->application->settings->save(); + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->dispatch('refreshQueue'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function force_start() { try { + $this->authorize('deploy', $this->application); force_start_deployment($this->application_deployment_queue); } catch (\Throwable $e) { return handleError($e, $this); @@ -58,10 +69,15 @@ class DeploymentNavbar extends Component return ''; } + $isMember = auth()->user()->isMember(); + $markdown = "# Deployment Logs\n\n"; $markdown .= "```\n"; foreach ($logs as $log) { + if ($isMember && ! empty($log['hidden'])) { + continue; + } if (isset($log['output'])) { $markdown .= $log['output']."\n"; } @@ -74,6 +90,11 @@ class DeploymentNavbar extends Component public function cancel() { + try { + $this->authorize('deploy', $this->application); + } catch (\Throwable $e) { + return handleError($e, $this); + } $deployment_uuid = $this->application_deployment_queue->deployment_uuid; $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 89b1b4217..7af0a275d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -13,7 +13,6 @@ use Illuminate\Support\Collection; use Livewire\Component; use Livewire\Features\SupportEvents\Event; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; class General extends Component { @@ -549,7 +548,7 @@ class General extends Component try { $this->authorize('update', $this->application); - $uuid = new Cuid2; + $uuid = new_public_id(); $domain = generateUrl(server: $this->application->destination->server, random: $uuid); $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index a46b2f19c..b7750e087 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -7,7 +7,6 @@ use App\Actions\Docker\GetContainersStatus; use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Heading extends Component { @@ -65,107 +64,123 @@ class Heading extends Component public function force_deploy_without_cache() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->deploy(force_rebuild: true); + $this->deploy(force_rebuild: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function deploy(bool $force_rebuild = false) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { - $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); + if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { + $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); - return; + return; + } + if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.'); + + return; + } + if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.
More information here: documentation'); + + return; + } + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); + + return; + } + $this->setDeploymentUuid(); + $result = queue_application_deployment( + application: $this->application, + deployment_uuid: $this->deploymentUuid, + force_rebuild: $force_rebuild, + ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + if ($result['status'] === 'skipped') { + $this->dispatch('error', 'Deployment skipped', $result['message']); + + return; + } + + return $this->redirectRoute('project.application.deployment.show', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + 'deployment_uuid' => $this->deploymentUuid, + 'environment_uuid' => $this->parameters['environment_uuid'], + ], navigate: false); + } catch (\Throwable $e) { + return handleError($e, $this); } - if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.'); - - return; - } - if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.
More information here: documentation'); - - return; - } - if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); - - return; - } - $this->setDeploymentUuid(); - $result = queue_application_deployment( - application: $this->application, - deployment_uuid: $this->deploymentUuid, - force_rebuild: $force_rebuild, - ); - if ($result['status'] === 'queue_full') { - $this->dispatch('error', 'Deployment queue full', $result['message']); - - return; - } - if ($result['status'] === 'skipped') { - $this->dispatch('error', 'Deployment skipped', $result['message']); - - return; - } - - return $this->redirectRoute('project.application.deployment.show', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'application_uuid' => $this->parameters['application_uuid'], - 'deployment_uuid' => $this->deploymentUuid, - 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); } protected function setDeploymentUuid() { - $this->deploymentUuid = new Cuid2; + $this->deploymentUuid = new_public_id(); $this->parameters['deployment_uuid'] = $this->deploymentUuid; } public function stop() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); - StopApplication::dispatch($this->application, false, $this->docker_cleanup); + $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); + StopApplication::dispatch($this->application, false, $this->docker_cleanup); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function restart() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); - return; + return; + } + + $this->setDeploymentUuid(); + $result = queue_application_deployment( + application: $this->application, + deployment_uuid: $this->deploymentUuid, + restart_only: true, + ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + if ($result['status'] === 'skipped') { + $this->dispatch('success', 'Deployment skipped', $result['message']); + + return; + } + + return $this->redirectRoute('project.application.deployment.show', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + 'deployment_uuid' => $this->deploymentUuid, + 'environment_uuid' => $this->parameters['environment_uuid'], + ], navigate: false); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $this->setDeploymentUuid(); - $result = queue_application_deployment( - application: $this->application, - deployment_uuid: $this->deploymentUuid, - restart_only: true, - ); - if ($result['status'] === 'queue_full') { - $this->dispatch('error', 'Deployment queue full', $result['message']); - - return; - } - if ($result['status'] === 'skipped') { - $this->dispatch('success', 'Deployment skipped', $result['message']); - - return; - } - - return $this->redirectRoute('project.application.deployment.show', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'application_uuid' => $this->parameters['application_uuid'], - 'deployment_uuid' => $this->deploymentUuid, - 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); } public function render() diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 59b52f557..74b2ebce8 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -9,7 +9,6 @@ use App\Models\ApplicationPreview; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Previews extends Component { @@ -234,31 +233,38 @@ class Previews extends Component public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $dockerRegistryImageTag = null; - if ($this->application->build_pack === 'dockerimage') { - $dockerRegistryImageTag = $this->application->previews() - ->where('pull_request_id', $pull_request_id) - ->value('docker_registry_image_tag'); + $dockerRegistryImageTag = null; + if ($this->application->build_pack === 'dockerimage') { + $dockerRegistryImageTag = $this->application->previews() + ->where('pull_request_id', $pull_request_id) + ->value('docker_registry_image_tag'); + } + + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag); } public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag); - $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag); + $this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag); + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null) { - $this->authorize('deploy', $this->application); - try { + $this->authorize('deploy', $this->application); $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) { @@ -305,7 +311,7 @@ class Previews extends Component protected function setDeploymentUuid() { - $this->deployment_uuid = new Cuid2; + $this->deployment_uuid = new_public_id(); $this->parameters['deployment_uuid'] = $this->deployment_uuid; } @@ -350,9 +356,8 @@ class Previews extends Component public function stop(int $pull_request_id) { - $this->authorize('deploy', $this->application); - try { + $this->authorize('deploy', $this->application); $server = $this->application->destination->server; if ($this->application->destination->server->isSwarm()) { diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 85ba2328e..e8da3b45c 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -6,7 +6,6 @@ use App\Models\ApplicationPreview; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; class PreviewsCompose extends Component { @@ -64,7 +63,7 @@ class PreviewsCompose extends Component if (empty($domain_string)) { $server = $this->preview->application->destination->server; $template = $this->preview->application->preview_url_template; - $random = new Cuid2; + $random = new_public_id(); // Generate a unique domain like main app services do $generated_fqdn = generateUrl(server: $server, random: $random); @@ -79,7 +78,7 @@ class PreviewsCompose extends Component $domain_list = explode(',', $domain_string); $preview_fqdns = []; $template = $this->preview->application->preview_url_template; - $random = new Cuid2; + $random = new_public_id(); foreach ($domain_list as $single_domain) { $single_domain = trim($single_domain); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index 3edd77833..b070ae1cc 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -6,7 +6,6 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Rollback extends Component { @@ -52,7 +51,7 @@ class Rollback extends Component $commit = validateGitRef($commit, 'rollback commit'); - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $this->application, diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 197dc41ed..94d627e67 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Swarm extends Component { + use AuthorizesRequests; + public Application $application; #[Validate('required')] @@ -51,6 +54,7 @@ class Swarm extends Component public function instantSave() { try { + $this->authorize('update', $this->application); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); } catch (\Throwable $e) { @@ -61,6 +65,7 @@ class Swarm extends Component public function submit() { try { + $this->authorize('update', $this->application); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 644753c83..0a6e3d8ec 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -11,11 +11,13 @@ use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class CloneMe extends Component { + use AuthorizesRequests; + public string $project_uuid; public string $environment_uuid; @@ -61,7 +63,7 @@ class CloneMe extends Component ->servers() ->get() ->reject(fn ($server) => $server->isBuildServer()); - $this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug(); + $this->newName = str($this->project->name.'-clone-'.new_public_id())->slug(); } public function toggleVolumeCloning(bool $value) @@ -91,6 +93,7 @@ class CloneMe extends Component public function clone(string $type) { try { + $this->authorize('create', Project::class); $this->validate([ 'selectedDestination' => 'required', 'newName' => ValidationPatterns::nameRules(), @@ -108,7 +111,7 @@ class CloneMe extends Component if ($this->environment->name !== 'production') { $project->environments()->create([ 'name' => $this->environment->name, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); } $environment = $project->environments->where('name', $this->environment->name)->first(); @@ -120,7 +123,7 @@ class CloneMe extends Component $project = $this->project; $environment = $this->project->environments()->create([ 'name' => $this->newName, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); } $applications = $this->environment->applications; @@ -134,7 +137,7 @@ class CloneMe extends Component } foreach ($databases as $database) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newDatabase = $database->replicate([ 'id', 'created_at', @@ -225,7 +228,7 @@ class CloneMe extends Component $scheduledBackups = $database->scheduledBackups()->get(); foreach ($scheduledBackups as $backup) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newBackup = $backup->replicate([ 'id', 'created_at', @@ -254,7 +257,7 @@ class CloneMe extends Component } foreach ($services as $service) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newService = $service->replicate([ 'id', 'created_at', @@ -278,7 +281,7 @@ class CloneMe extends Component 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'service_id' => $newService->id, 'team_id' => currentTeam()->id, ]); @@ -409,7 +412,7 @@ class CloneMe extends Component $scheduledBackups = $database->scheduledBackups()->get(); foreach ($scheduledBackups as $backup) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newBackup = $backup->replicate([ 'id', 'created_at', diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index ef106a65f..99426c120 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database; +use App\Jobs\DatabaseBackupJob; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Exception; @@ -190,6 +191,18 @@ class BackupEdit extends Component } } + public function backupNow() + { + try { + $this->authorize('manageBackups', $this->backup->database); + + DatabaseBackupJob::dispatch($this->backup); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 1dd93781d..41fb1681b 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -3,12 +3,16 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Livewire\Component; class BackupExecutions extends Component { + use AuthorizesRequests; + public ?ScheduledDatabaseBackup $backup = null; public $database; @@ -44,29 +48,45 @@ class BackupExecutions extends Component public function cleanupFailed() { - if ($this->backup) { - $this->backup->executions()->where('status', 'failed')->delete(); - $this->refreshBackupExecutions(); - $this->dispatch('success', 'Failed backups cleaned up.'); + try { + $this->authorize('manageBackups', $this->database); + if ($this->backup) { + $this->backup->executions()->where('status', 'failed')->delete(); + $this->refreshBackupExecutions(); + $this->dispatch('success', 'Failed backups cleaned up.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); } } public function cleanupDeleted() { - if ($this->backup) { - $deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count(); - if ($deletedCount > 0) { - $this->backup->executions()->where('local_storage_deleted', true)->delete(); - $this->refreshBackupExecutions(); - $this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage."); - } else { - $this->dispatch('info', 'No backup entries found that are deleted from local storage.'); + try { + $this->authorize('manageBackups', $this->database); + if ($this->backup) { + $deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count(); + if ($deletedCount > 0) { + $this->backup->executions()->where('local_storage_deleted', true)->delete(); + $this->refreshBackupExecutions(); + $this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage."); + } else { + $this->dispatch('info', 'No backup entries found that are deleted from local storage.'); + } } + } catch (\Throwable $e) { + return handleError($e, $this); } } public function deleteBackup($executionId, $password, $selectedActions = []) { + try { + $this->authorize('manageBackups', $this->database); + } catch (\Throwable $e) { + return handleError($e, $this); + } + if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; } @@ -78,7 +98,7 @@ class BackupExecutions extends Component return; } - $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class + $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class ? $execution->scheduledDatabaseBackup->database->service->destination->server : $execution->scheduledDatabaseBackup->database->destination->server; @@ -185,7 +205,7 @@ class BackupExecutions extends Component if ($this->database) { $server = null; - if ($this->database instanceof \App\Models\ServiceDatabase) { + if ($this->database instanceof ServiceDatabase) { $server = $this->database->service->destination->server; } elseif ($this->database->destination && $this->database->destination->server) { $server = $this->database->destination->server; diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index decd59a4c..e4ed2a366 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -14,9 +14,13 @@ class BackupNow extends Component public function backupNow() { - $this->authorize('manageBackups', $this->backup->database); + try { + $this->authorize('manageBackups', $this->backup->database); - DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + DatabaseBackupJob::dispatch($this->backup); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 694674326..ad5e45b3f 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -42,6 +42,8 @@ class General extends Component public bool $isLogDrainEnabled = false; + public bool $isPasswordHiddenForMember = false; + public function getListeners(): array { $user = Auth::user(); @@ -72,6 +74,11 @@ class General extends Component } catch (\Throwable $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->clickhouseAdminPassword = ''; + } } protected function rules(): array diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index f196b9dfb..2f5b84484 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -40,6 +40,8 @@ class General extends Component public bool $isLogDrainEnabled = false; + public bool $isPasswordHiddenForMember = false; + public function getListeners(): array { $user = Auth::user(); @@ -70,6 +72,11 @@ class General extends Component } catch (\Throwable $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->dragonflyPassword = ''; + } } protected function rules(): array diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index c6c9a3c48..943f22702 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -90,18 +90,28 @@ class Heading extends Component public function restart() { - $this->authorize('manage', $this->database); + try { + $this->authorize('manage', $this->database); - $activity = RestartDatabase::run($this->database); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $activity = RestartDatabase::run($this->database); + $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); + $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function start() { - $this->authorize('manage', $this->database); + try { + $this->authorize('manage', $this->database); - $activity = StartDatabase::run($this->database); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $activity = StartDatabase::run($this->database); + $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); + $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Database/InitScript.php b/app/Livewire/Project/Database/InitScript.php index e3baa1c8e..7074c235d 100644 --- a/app/Livewire/Project/Database/InitScript.php +++ b/app/Livewire/Project/Database/InitScript.php @@ -2,13 +2,20 @@ namespace App\Livewire\Project\Database; +use App\Models\StandalonePostgresql; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class InitScript extends Component { + use AuthorizesRequests; + + #[Locked] + public StandalonePostgresql $database; + #[Locked] public array $script; @@ -35,6 +42,7 @@ class InitScript extends Component public function submit() { try { + $this->authorize('update', $this->database); $this->validate(); $this->script['index'] = $this->index; $this->script['content'] = $this->content; @@ -48,6 +56,7 @@ class InitScript extends Component public function delete() { try { + $this->authorize('update', $this->database); $this->dispatch('delete_init_script', $this->script); } catch (Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 974803e8d..b2d9bce91 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -42,6 +42,8 @@ class General extends Component public bool $isLogDrainEnabled = false; + public bool $isPasswordHiddenForMember = false; + public function getListeners(): array { $user = Auth::user(); @@ -72,6 +74,11 @@ class General extends Component } catch (\Throwable $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->keydbPassword = ''; + } } protected function rules(): array diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 41266f152..61280a34b 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -47,6 +47,8 @@ class General extends Component public ?string $customDockerRunOptions = null; + public bool $isPasswordHiddenForMember = false; + protected function rules(): array { return [ @@ -126,6 +128,12 @@ class General extends Component } catch (Exception $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->mariadbRootPassword = ''; + $this->mariadbPassword = ''; + } } public function syncData(bool $toModel = false) diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 7d8249ec4..f68ba82c7 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -45,6 +45,8 @@ class General extends Component public ?string $customDockerRunOptions = null; + public bool $isPasswordHiddenForMember = false; + protected function rules(): array { return [ @@ -119,6 +121,11 @@ class General extends Component } catch (Exception $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->mongoInitdbRootPassword = ''; + } } public function syncData(bool $toModel = false) diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 6b88d735d..1adfe2ea7 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -47,6 +47,8 @@ class General extends Component public ?string $customDockerRunOptions = null; + public bool $isPasswordHiddenForMember = false; + protected function rules(): array { return [ @@ -126,6 +128,12 @@ class General extends Component } catch (Exception $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->mysqlRootPassword = ''; + $this->mysqlPassword = ''; + } } public function syncData(bool $toModel = false) diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 4e89e8b62..8993cc251 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -55,6 +55,8 @@ class General extends Component public string $new_content; + public bool $isPasswordHiddenForMember = false; + protected $listeners = [ 'save_init_script', 'delete_init_script', @@ -140,6 +142,11 @@ class General extends Component } catch (Exception $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->postgresPassword = ''; + } } public function syncData(bool $toModel = false) diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index aff7b7afa..d431b1506 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -45,6 +45,8 @@ class General extends Component public string $redisVersion; + public bool $isPasswordHiddenForMember = false; + protected $listeners = [ 'envsUpdated' => 'refresh', ]; @@ -118,6 +120,11 @@ class General extends Component } catch (\Throwable $e) { return handleError($e, $this); } + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->redisPassword = ''; + } } public function syncData(bool $toModel = false) diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index 1cf5e53f6..9d1f212e4 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -34,7 +35,7 @@ class ScheduledBackups extends Component $this->setSelectedBackup($this->selectedBackupId, true); } $this->parameters = get_route_parameters(); - if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->database->getMorphClass() === ServiceDatabase::class) { $this->type = 'service-database'; } else { $this->type = 'database'; @@ -56,22 +57,30 @@ class ScheduledBackups extends Component public function setCustomType() { - $this->authorize('update', $this->database); + try { + $this->authorize('update', $this->database); - $this->database->custom_type = $this->custom_type; - $this->database->save(); - $this->dispatch('success', 'Database type set.'); - $this->refreshScheduledBackups(); + $this->database->custom_type = $this->custom_type; + $this->database->save(); + $this->dispatch('success', 'Database type set.'); + $this->refreshScheduledBackups(); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function delete($scheduled_backup_id): void { - $backup = $this->database->scheduledBackups->find($scheduled_backup_id); - $this->authorize('manageBackups', $this->database); + try { + $this->authorize('manageBackups', $this->database); - $backup->delete(); - $this->dispatch('success', 'Scheduled backup deleted.'); - $this->refreshScheduledBackups(); + $backup = $this->database->scheduledBackups->find($scheduled_backup_id); + $backup->delete(); + $this->dispatch('success', 'Scheduled backup deleted.'); + $this->refreshScheduledBackups(); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function refreshScheduledBackups(?int $id = null): void diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 4d28c7676..3d7767699 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -28,18 +28,22 @@ class DeleteEnvironment extends Component public function delete() { - $this->validate([ - 'environment_id' => 'required|int', - ]); - $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id); - $this->authorize('delete', $environment); + try { + $this->validate([ + 'environment_id' => 'required|int', + ]); + $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id); + $this->authorize('delete', $environment); - if ($environment->isEmpty()) { - $environment->delete(); + if ($environment->isEmpty()) { + $environment->delete(); - return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); + return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); + } + + return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); + } catch (\Throwable $e) { + return handleError($e, $this); } - - return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); } } diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index d95041c2d..598e1f61b 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -26,18 +26,22 @@ class DeleteProject extends Component public function delete() { - $this->validate([ - 'project_id' => 'required|int', - ]); - $project = Project::ownedByCurrentTeam()->findOrFail($this->project_id); - $this->authorize('delete', $project); + try { + $this->validate([ + 'project_id' => 'required|int', + ]); + $project = Project::ownedByCurrentTeam()->findOrFail($this->project_id); + $this->authorize('delete', $project); - if ($project->isEmpty()) { - $project->delete(); + if ($project->isEmpty()) { + $project->delete(); - return redirectRoute($this, 'project.index'); + return redirectRoute($this, 'project.index'); + } + + return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); + } catch (\Throwable $e) { + return handleError($e, $this); } - - return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); } } diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index a2d73eb5f..1314c9e4b 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -4,10 +4,13 @@ namespace App\Livewire\Project; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Edit extends Component { + use AuthorizesRequests; + public Project $project; public string $name; @@ -54,6 +57,7 @@ class Edit extends Component public function submit() { try { + $this->authorize('update', $this->project); $this->syncData(true); $this->dispatch('success', 'Project updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index 529b9d7b1..9b9a3670d 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -5,11 +5,14 @@ namespace App\Livewire\Project; use App\Models\Application; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Component; class EnvironmentEdit extends Component { + use AuthorizesRequests; + public Project $project; public Application $application; @@ -62,6 +65,7 @@ class EnvironmentEdit extends Component public function submit() { try { + $this->authorize('update', $this->environment); $this->syncData(true); redirectRoute($this, 'project.environment.edit', [ 'environment_uuid' => $this->environment->uuid, diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 737806cb8..de86bea4a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -7,7 +7,6 @@ use App\Models\Project; use App\Services\DockerImageParser; use App\Support\ValidationPatterns; use Livewire\Component; -use Visus\Cuid2\Cuid2; class DockerImage extends Component { @@ -130,7 +129,7 @@ class DockerImage extends Component $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); $application = Application::create([ - 'name' => 'docker-image-'.new Cuid2, + 'name' => 'docker-image-'.new_public_id(), 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main', diff --git a/app/Livewire/Project/New/EmptyProject.php b/app/Livewire/Project/New/EmptyProject.php index 0360365a9..7c92ce96b 100644 --- a/app/Livewire/Project/New/EmptyProject.php +++ b/app/Livewire/Project/New/EmptyProject.php @@ -4,7 +4,6 @@ namespace App\Livewire\Project\New; use App\Models\Project; use Livewire\Component; -use Visus\Cuid2\Cuid2; class EmptyProject extends Component { @@ -13,7 +12,7 @@ class EmptyProject extends Component $project = Project::create([ 'name' => generate_random_name(), 'team_id' => currentTeam()->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index d6d234b18..cff886f98 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -6,7 +6,6 @@ use App\Models\Project; use App\Models\Server; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component @@ -107,7 +106,7 @@ class Select extends Component public function loadServices() { $services = get_service_templates(); - $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys()); + $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { $default_logo = 'images/default.webp'; @@ -279,19 +278,31 @@ class Select extends Component return $this->formatLastModified($this->serviceTemplatesPath()); } - private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array + private function serviceTemplateLastUpdatedMap(Collection $services): array { - $bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0; + return $services + ->mapWithKeys(fn ($service, $serviceName) => [ + (string) $serviceName => $this->serviceTemplateLastUpdatedFromPayload($service) + ?? $this->serviceTemplateLastUpdated((string) $serviceName), + ]) + ->all(); + } - return Cache::remember( - "service-template-last-updated-map:{$bundleMtime}", - now()->addDay(), - fn () => $serviceNames - ->mapWithKeys(fn ($serviceName) => [ - (string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName), - ]) - ->all() - ); + private function serviceTemplateLastUpdatedFromPayload(mixed $service): ?string + { + $timestamp = data_get($service, 'template_last_updated_at'); + + if (! is_string($timestamp) || $timestamp === '') { + return null; + } + + try { + return CarbonImmutable::parse($timestamp) + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } catch (\Throwable) { + return null; + } } private function serviceTemplateLastUpdated(string $serviceName): ?string diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index f07948dba..5a84343fd 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -6,7 +6,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; use Livewire\Component; -use Visus\Cuid2\Cuid2; class SimpleDockerfile extends Component { @@ -48,7 +47,7 @@ CMD ["nginx", "-g", "daemon off;"] $port = 80; } $application = Application::create([ - 'name' => 'dockerfile-'.new Cuid2, + 'name' => 'dockerfile-'.new_public_id(), 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main', diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index 32cf72067..0f5c739b1 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project\Service; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class EditCompose extends Component { + use AuthorizesRequests; + public Service $service; public $serviceId; @@ -72,19 +75,29 @@ class EditCompose extends Component public function saveEditedCompose() { - $this->dispatch('info', 'Saving new docker compose...'); - $this->dispatch('saveCompose', $this->dockerComposeRaw); - $this->dispatch('refreshStorages'); + try { + $this->authorize('update', $this->service); + $this->dispatch('info', 'Saving new docker compose...'); + $this->dispatch('saveCompose', $this->dockerComposeRaw); + $this->dispatch('refreshStorages'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function instantSave() { - $this->validate([ - 'isContainerLabelEscapeEnabled' => 'required', - ]); - $this->syncData(true); - $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]); - $this->dispatch('success', 'Service updated successfully'); + try { + $this->authorize('update', $this->service); + $this->validate([ + 'isContainerLabelEscapeEnabled' => 'required', + ]); + $this->syncData(true); + $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]); + $this->dispatch('success', 'Service updated successfully'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2f1a229b4..877142d8b 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -110,8 +110,7 @@ class FileStorage extends Component public function loadStorageOnServer() { try { - // Loading content is a read operation, so we use 'view' permission - $this->authorize('view', $this->resource); + $this->authorize('update', $this->resource); $this->fileStorage->loadStorageOnServer(); $this->syncData(); diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 60273ab23..34bb46ff1 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -110,17 +110,20 @@ class Heading extends Component public function start() { - $this->authorizeService('deploy'); - - $activity = StartService::run($this->service, pullLatestImages: true); - $this->dispatch('activityMonitor', $activity->id); + try { + $this->authorizeService('deploy'); + $activity = StartService::run($this->service, pullLatestImages: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function forceDeploy() { - $this->authorizeService('deploy'); - try { + $this->authorizeService('deploy'); $activities = Activity::where('properties->type_uuid', $this->service->uuid) ->where(function ($q) { $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) @@ -131,49 +134,57 @@ class Heading extends Component $activity->save(); } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } public function stop() { - $this->authorizeService('stop'); - try { + $this->authorizeService('stop'); StopService::dispatch($this->service, false, $this->docker_cleanup); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } public function restart() { - $this->authorizeService('deploy'); + try { + $this->authorizeService('deploy'); + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); - - return; + return; + } + $activity = StartService::run($this->service, stopBeforeStart: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); } - $activity = StartService::run($this->service, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id); } public function pullAndRestartEvent() { - $this->authorizeService('deploy'); + try { + $this->authorizeService('deploy'); + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); - - return; + return; + } + $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); } - $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id); } private function authorizeService(string $ability): void diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 64a7d8d8b..86d5a57c1 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -4,16 +4,21 @@ namespace App\Livewire\Project\Service; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Livewire\Component; class StackForm extends Component { + use AuthorizesRequests; + public Service $service; public Collection $fields; + public bool $isPasswordHiddenForMember = false; + protected $listeners = ['saveCompose']; // Explicit properties @@ -118,6 +123,17 @@ class StackForm extends Component })->flatMap(function ($group) { return $group; }); + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->fields = $this->fields->map(function ($field) { + if (data_get($field, 'isPassword')) { + $field['value'] = null; + } + + return $field; + }); + } } public function saveCompose($raw) @@ -128,14 +144,20 @@ class StackForm extends Component public function instantSave() { - $this->syncData(true); - $this->service->save(); - $this->dispatch('success', 'Service settings saved.'); + try { + $this->authorize('update', $this->service); + $this->syncData(true); + $this->service->save(); + $this->dispatch('success', 'Service settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function submit($notify = true) { try { + $this->authorize('update', $this->service); $this->validate(); $this->syncData(true); diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index caaabc494..7f0d3b173 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -8,7 +8,6 @@ use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Danger extends Component { @@ -39,7 +38,7 @@ class Danger extends Component public function mount() { $parameters = get_route_parameters(); - $this->modalId = new Cuid2; + $this->modalId = new_public_id(); $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 715ce82a7..4f3e659da 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -7,12 +7,14 @@ use App\Actions\Docker\GetContainersStatus; use App\Events\ApplicationStatusChanged; use App\Models\Server; use App\Models\StandaloneDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Destination extends Component { + use AuthorizesRequests; + public $resource; public Collection $networks; @@ -59,6 +61,7 @@ class Destination extends Component public function stop($serverId) { try { + $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); $this->refreshServers(); @@ -70,12 +73,13 @@ class Destination extends Component public function redeploy(int $network_id, int $server_id) { try { + $this->authorize('deploy', $this->resource); if ($this->resource->additional_servers->count() > 0 && str($this->resource->docker_registry_image_name)->isEmpty()) { $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); return; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $server = Server::ownedByCurrentTeam()->findOrFail($server_id); $destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail(); $result = queue_application_deployment( @@ -128,7 +132,7 @@ class Destination extends Component }); $this->resource->refresh(); $this->refreshServers(); - } catch (\Exception $e) { + } catch (\Throwable $e) { return handleError($e, $this); } } @@ -149,7 +153,7 @@ class Destination extends Component $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]); $this->dispatch('refresh'); - } catch (\Exception $e) { + } catch (\Throwable $e) { return handleError($e, $this); } } @@ -157,6 +161,7 @@ class Destination extends Component public function removeServer(int $network_id, int $server_id, $password, $selectedActions = []) { try { + $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index a19837e16..3a5145023 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -111,7 +111,7 @@ class All extends Component $query->orderBy('order'); } - return $query->get(); + return $this->nullLockedValues($query->get()); } private function searchTerm(): string @@ -127,6 +127,20 @@ class All extends Component $this->hardcodedEnvironmentVariablesPreview->isNotEmpty(); } + private function nullLockedValues($envs) + { + $isMember = auth()->user()?->isMember(); + + $envs->each(function ($env) use ($isMember) { + if ($env->is_shown_once || $isMember) { + $env->value = null; + $env->real_value = null; + } + }); + + return $envs; + } + public function getIsSearchActiveProperty(): bool { return $this->searchTerm() !== ''; @@ -204,7 +218,12 @@ class All extends Component private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 26369852e..094320bdd 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -61,6 +61,8 @@ class Show extends Component public bool $is_redis_credential = false; + public bool $isValueHidden = false; + public array $problematicVariables = []; protected $listeners = [ @@ -160,6 +162,13 @@ class Show extends Component $this->is_really_required = $this->env->is_really_required ?? false; $this->is_shared = $this->env->is_shared ?? false; $this->real_value = $this->env->real_value; + + if ($this->env->is_shown_once || auth()->user()?->isMember()) { + $this->value = null; + $this->real_value = null; + } + + $this->isValueHidden = auth()->user()?->isMember() ?? false; } } diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 4ea5e12db..3fa063298 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -6,12 +6,15 @@ use App\Models\Application; use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\On; use Livewire\Component; class ExecuteContainerCommand extends Component { + use AuthorizesRequests; + public $selected_container = 'default'; public Collection $containers; @@ -40,6 +43,7 @@ class ExecuteContainerCommand extends Component if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->authorize('view', $this->resource); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -56,6 +60,7 @@ class ExecuteContainerCommand extends Component abort(404); } $this->resource = $resource; + $this->authorize('view', $this->resource); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -63,6 +68,7 @@ class ExecuteContainerCommand extends Component } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->authorize('view', $this->resource); if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } @@ -70,6 +76,7 @@ class ExecuteContainerCommand extends Component } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; $this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail(); + $this->authorize('view', $this->resource); $this->servers = $this->servers->push($this->resource); } $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); @@ -152,7 +159,9 @@ class ExecuteContainerCommand extends Component public function connectToServer() { try { + $this->authorize('canAccessTerminal'); $server = $this->servers->first(); + $this->authorize('view', $server); if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } @@ -181,6 +190,7 @@ class ExecuteContainerCommand extends Component return; } try { + $this->authorize('canAccessTerminal'); // Validate container name format if (! ValidationPatterns::isValidContainerName($this->selected_container)) { throw new \InvalidArgumentException('Invalid container name format'); @@ -198,6 +208,8 @@ class ExecuteContainerCommand extends Component throw new \RuntimeException('Invalid server configuration.'); } + $this->authorize('view', $server); + if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 195e7fd92..5fa62b04e 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -78,8 +78,12 @@ class HealthChecks extends Component public function mount() { - $this->authorize('view', $this->resource); - $this->syncData(); + try { + $this->authorize('view', $this->resource); + $this->syncData(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function syncData(bool $toModel = false): void diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 2a8747c33..02171af8d 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -22,7 +22,6 @@ use App\Models\StandaloneRedis; use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class ResourceOperations extends Component { @@ -56,224 +55,86 @@ class ResourceOperations extends Component public function cloneTo($destination_id) { - $this->authorize('update', $this->resource); + try { + $this->authorize('update', $this->resource); - $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); - if (! $new_destination) { - $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); - } - if (! $new_destination) { - return $this->addError('destination_id', 'Destination not found.'); - } - $uuid = (string) new Cuid2; - $server = $new_destination->server; - - if ($this->resource->getMorphClass() === Application::class) { - $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); - - $route = route('project.application.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'application_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); - } elseif ( - $this->resource->getMorphClass() === StandalonePostgresql::class || - $this->resource->getMorphClass() === StandaloneMongodb::class || - $this->resource->getMorphClass() === StandaloneMysql::class || - $this->resource->getMorphClass() === StandaloneMariadb::class || - $this->resource->getMorphClass() === StandaloneRedis::class || - $this->resource->getMorphClass() === StandaloneKeydb::class || - $this->resource->getMorphClass() === StandaloneDragonfly::class || - $this->resource->getMorphClass() === StandaloneClickhouse::class - ) { - $uuid = (string) new Cuid2; - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, - 'status' => 'exited', - 'started_at' => null, - 'destination_id' => $new_destination->id, - ]); - $new_resource->save(); - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); + $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); + if (! $new_destination) { + $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); } - - $new_resource->persistentStorages()->delete(); - $persistentVolumes = $this->resource->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $originalName = $volume->name; - $newName = ''; - - if (str_starts_with($originalName, 'postgres-data-')) { - $newName = 'postgres-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mysql-data-')) { - $newName = 'mysql-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'redis-data-')) { - $newName = 'redis-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'clickhouse-data-')) { - $newName = 'clickhouse-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mariadb-data-')) { - $newName = 'mariadb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mongodb-data-')) { - $newName = 'mongodb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'keydb-data-')) { - $newName = 'keydb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'dragonfly-data-')) { - $newName = 'dragonfly-data-'.$new_resource->uuid; - } else { - if (str_starts_with($volume->name, $this->resource->uuid)) { - $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); - } else { - $newName = $new_resource->uuid.'-'.$volume->name; - } - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - 'uuid', - ])->fill([ - 'name' => $newName, - 'resource_id' => $new_resource->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($this->resource); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($this->resource); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } + if (! $new_destination) { + return $this->addError('destination_id', 'Destination not found.'); } + $uuid = new_public_id(); + $server = $new_destination->server; - $fileStorages = $this->resource->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $new_resource->id, - ]); - $newStorage->save(); - } + if ($this->resource->getMorphClass() === Application::class) { + $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); - $scheduledBackups = $this->resource->scheduledBackups()->get(); - foreach ($scheduledBackups as $backup) { - $uuid = (string) new Cuid2; - $newBackup = $backup->replicate([ + $route = route('project.application.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'application_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); + } elseif ( + $this->resource->getMorphClass() === StandalonePostgresql::class || + $this->resource->getMorphClass() === StandaloneMongodb::class || + $this->resource->getMorphClass() === StandaloneMysql::class || + $this->resource->getMorphClass() === StandaloneMariadb::class || + $this->resource->getMorphClass() === StandaloneRedis::class || + $this->resource->getMorphClass() === StandaloneKeydb::class || + $this->resource->getMorphClass() === StandaloneDragonfly::class || + $this->resource->getMorphClass() === StandaloneClickhouse::class + ) { + $uuid = new_public_id(); + $new_resource = $this->resource->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ 'uuid' => $uuid, - 'database_id' => $new_resource->id, - 'database_type' => $new_resource->getMorphClass(), - 'team_id' => currentTeam()->id, - ]); - $newBackup->save(); - } - - $environmentVaribles = $this->resource->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $payload = [ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]; - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill($payload); - $newEnvironmentVariable->save(); - } - - $route = route('project.database.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'database_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); - } elseif ($this->resource->type() === 'service') { - $uuid = (string) new Cuid2; - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, - 'destination_id' => $new_destination->id, - 'destination_type' => $new_destination->getMorphClass(), - 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column) - ]); - - $new_resource->save(); - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } - - $scheduledTasks = $this->resource->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'service_id' => $new_resource->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $environmentVariables = $this->resource->environment_variables()->get(); - foreach ($environmentVariables as $environmentVariable) { - $newEnvironmentVariable = $environmentVariable->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } - - foreach ($new_resource->applications() as $application) { - $application->fill([ + 'name' => $this->resource->name.'-clone-'.$uuid, 'status' => 'exited', - ])->save(); + 'started_at' => null, + 'destination_id' => $new_destination->id, + ]); + $new_resource->save(); - $persistentVolumes = $application->persistentStorages()->get(); + $tags = $this->resource->tags; + foreach ($tags as $tag) { + $new_resource->tags()->attach($tag->id); + } + + $new_resource->persistentStorages()->delete(); + $persistentVolumes = $this->resource->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { + $originalName = $volume->name; $newName = ''; - if (str_starts_with($volume->name, $volume->resource->uuid)) { - $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid); + + if (str_starts_with($originalName, 'postgres-data-')) { + $newName = 'postgres-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mysql-data-')) { + $newName = 'mysql-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'redis-data-')) { + $newName = 'redis-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'clickhouse-data-')) { + $newName = 'clickhouse-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mariadb-data-')) { + $newName = 'mariadb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mongodb-data-')) { + $newName = 'mongodb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'keydb-data-')) { + $newName = 'keydb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'dragonfly-data-')) { + $newName = 'dragonfly-data-'.$new_resource->uuid; } else { - $newName = $application->uuid.'-'.str($volume->name)->afterLast('-'); + if (str_starts_with($volume->name, $this->resource->uuid)) { + $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); + } else { + $newName = $new_resource->uuid.'-'.$volume->name; + } } $newPersistentVolume = $volume->replicate([ @@ -283,80 +144,222 @@ class ResourceOperations extends Component 'uuid', ])->fill([ 'name' => $newName, - 'resource_id' => $application->id, + 'resource_id' => $new_resource->id, ]); $newPersistentVolume->save(); if ($this->cloneVolumeData) { try { - StopService::dispatch($application); + StopDatabase::dispatch($this->resource); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->service->destination->server; + $sourceServer = $this->resource->destination->server; $targetServer = $new_resource->destination->server; VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - StartService::dispatch($application); + StartDatabase::dispatch($this->resource); } catch (\Exception $e) { \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } } } - } - foreach ($new_resource->databases() as $database) { - $database->fill([ - 'status' => 'exited', - ])->save(); - - $persistentVolumes = $database->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $volume->resource->uuid)) { - $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid); - } else { - $newName = $database->uuid.'-'.str($volume->name)->afterLast('-'); - } - - $newPersistentVolume = $volume->replicate([ + $fileStorages = $this->resource->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ 'id', 'created_at', 'updated_at', - 'uuid', ])->fill([ - 'name' => $newName, - 'resource_id' => $database->id, + 'resource_id' => $new_resource->id, ]); - $newPersistentVolume->save(); + $newStorage->save(); + } - if ($this->cloneVolumeData) { - try { - StopService::dispatch($database->service); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->service->destination->server; - $targetServer = $new_resource->destination->server; + $scheduledBackups = $this->resource->scheduledBackups()->get(); + foreach ($scheduledBackups as $backup) { + $uuid = new_public_id(); + $newBackup = $backup->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'database_id' => $new_resource->id, + 'database_type' => $new_resource->getMorphClass(), + 'team_id' => currentTeam()->id, + ]); + $newBackup->save(); + } - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + $environmentVaribles = $this->resource->environment_variables()->get(); + foreach ($environmentVaribles as $environmentVarible) { + $payload = [ + 'resourceable_id' => $new_resource->id, + 'resourceable_type' => $new_resource->getMorphClass(), + ]; + $newEnvironmentVariable = $environmentVarible->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill($payload); + $newEnvironmentVariable->save(); + } - StartService::dispatch($database->service); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + $route = route('project.database.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'database_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); + } elseif ($this->resource->type() === 'service') { + $uuid = new_public_id(); + $new_resource = $this->resource->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'name' => $this->resource->name.'-clone-'.$uuid, + 'destination_id' => $new_destination->id, + 'destination_type' => $new_destination->getMorphClass(), + 'server_id' => $new_destination->server_id, + ]); + + $new_resource->save(); + + $tags = $this->resource->tags; + foreach ($tags as $tag) { + $new_resource->tags()->attach($tag->id); + } + + $scheduledTasks = $this->resource->scheduled_tasks()->get(); + foreach ($scheduledTasks as $task) { + $newTask = $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => new_public_id(), + 'service_id' => $new_resource->id, + 'team_id' => currentTeam()->id, + ]); + $newTask->save(); + } + + $environmentVariables = $this->resource->environment_variables()->get(); + foreach ($environmentVariables as $environmentVariable) { + $newEnvironmentVariable = $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $new_resource->id, + 'resourceable_type' => $new_resource->getMorphClass(), + ]); + $newEnvironmentVariable->save(); + } + + foreach ($new_resource->applications() as $application) { + $application->fill([ + 'status' => 'exited', + ])->save(); + + $persistentVolumes = $application->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $volume->resource->uuid)) { + $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid); + } else { + $newName = $application->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $application->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($application); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $application->service->destination->server; + $targetServer = $new_resource->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($application); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } } } } + + foreach ($new_resource->databases() as $database) { + $database->fill([ + 'status' => 'exited', + ])->save(); + + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $volume->resource->uuid)) { + $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid); + } else { + $newName = $database->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $database->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($database->service); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->service->destination->server; + $targetServer = $new_resource->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($database->service); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + } + + $new_resource->parse(); + + $route = route('project.service.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'service_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); } - - $new_resource->parse(); - - $route = route('project.service.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'service_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index db65cdaad..46c75e352 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -5,11 +5,14 @@ namespace App\Livewire\Project\Shared; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\On; use Livewire\Component; class Terminal extends Component { + use AuthorizesRequests; + public bool $hasShell = true; public bool $isTerminalConnected = false; @@ -32,7 +35,11 @@ class Terminal extends Component #[On('send-terminal-command')] public function sendTerminalCommand($isContainer, $identifier, $serverUuid) { + $this->authorize('canAccessTerminal'); + $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); + $this->authorize('view', $server); + if (! $server->isTerminalEnabled() || $server->isForceDisabled()) { abort(403, 'Terminal access is disabled on this server.'); } diff --git a/app/Livewire/Project/Shared/UploadConfig.php b/app/Livewire/Project/Shared/UploadConfig.php index 1b10f588b..0f0894687 100644 --- a/app/Livewire/Project/Shared/UploadConfig.php +++ b/app/Livewire/Project/Shared/UploadConfig.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project\Shared; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class UploadConfig extends Component { + use AuthorizesRequests; + public $config; public $applicationId; @@ -29,13 +32,12 @@ class UploadConfig extends Component public function uploadConfig() { try { - $application = Application::findOrFail($this->applicationId); + $application = Application::ownedByCurrentTeam()->findOrFail($this->applicationId); + $this->authorize('update', $application); $application->setConfig($this->config); $this->dispatch('success', 'Application settings updated'); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); - - return; + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Project/Shared/Webhooks.php b/app/Livewire/Project/Shared/Webhooks.php index eafc653d5..eb90262e9 100644 --- a/app/Livewire/Project/Shared/Webhooks.php +++ b/app/Livewire/Project/Shared/Webhooks.php @@ -34,19 +34,24 @@ class Webhooks extends Component { $this->deploywebhook = generateDeployWebhook($this->resource); - $this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github'); + if ($this->canViewSecrets()) { + $this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github'); + $this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab'); + $this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket'); + $this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea'); + } + $this->githubManualWebhook = generateGitManualWebhook($this->resource, 'github'); - - $this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab'); $this->gitlabManualWebhook = generateGitManualWebhook($this->resource, 'gitlab'); - - $this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket'); $this->bitbucketManualWebhook = generateGitManualWebhook($this->resource, 'bitbucket'); - - $this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea'); $this->giteaManualWebhook = generateGitManualWebhook($this->resource, 'gitea'); } + public function canViewSecrets(): bool + { + return auth()->user()->can('update', $this->resource); + } + public function submit() { try { diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index e884abb4e..fc84e4fbd 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -5,11 +5,13 @@ namespace App\Livewire\Project; use App\Models\Environment; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Show extends Component { + use AuthorizesRequests; + public Project $project; public string $name; @@ -41,11 +43,12 @@ class Show extends Component public function submit() { try { + $this->authorize('create', Environment::class); $this->validate(); $environment = Environment::create([ 'name' => $this->name, 'project_id' => $this->project->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); return redirectRoute($this, 'project.resource.index', [ diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index c275ec097..bf201e257 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -36,6 +36,12 @@ class ApiTokens extends Component #[Locked] public bool $canUseWritePermissions = false; + #[Locked] + public bool $canUseDeployPermissions = false; + + #[Locked] + public bool $canUseSensitivePermissions = false; + public function render() { return view('livewire.security.api-tokens'); @@ -46,6 +52,8 @@ class ApiTokens extends Component $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); + $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); + $this->canUseSensitivePermissions = auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class); $this->getTokens(); } @@ -56,10 +64,9 @@ class ApiTokens extends Component public function updatedPermissions($permissionToUpdate) { - // Check if user is trying to use restricted permissions + // Re-evaluate policies fresh — never trust stored snapshot booleans. if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use root permissions.'); - // Remove root from permissions if it was somehow added $this->permissions = array_diff($this->permissions, ['root']); return; @@ -67,12 +74,25 @@ class ApiTokens extends Component if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use write permissions.'); - // Remove write permissions if they were somehow added $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); return; } + if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { + $this->dispatch('error', 'You do not have permission to use deploy permissions.'); + $this->permissions = array_diff($this->permissions, ['deploy']); + + return; + } + + if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { + $this->dispatch('error', 'You do not have permission to use read:sensitive permissions.'); + $this->permissions = array_diff($this->permissions, ['read:sensitive']); + + return; + } + if ($permissionToUpdate == 'root') { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { @@ -92,7 +112,9 @@ class ApiTokens extends Component try { $this->authorize('create', PersonalAccessToken::class); - // Validate permissions based on user role + // Re-evaluate policies fresh against the current authenticated user. + // Never trust $this->canUse* booleans — they come from the Livewire + // snapshot which can be replayed from another user's session. if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with root permissions.'); } @@ -101,6 +123,14 @@ class ApiTokens extends Component throw new \Exception('You do not have permission to create tokens with write permissions.'); } + if (in_array('deploy', $this->permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { + throw new \Exception('You do not have permission to create tokens with deploy permissions.'); + } + + if (in_array('read:sensitive', $this->permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { + throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); + } + $this->validate([ 'description' => 'required|min:3|max:255', 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', @@ -108,6 +138,7 @@ class ApiTokens extends Component $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); $this->getTokens(); + // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. session()->flash('token', $token->plainTextToken); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php index 33beff334..c7f933d39 100644 --- a/app/Livewire/Security/CloudInitScriptForm.php +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -3,6 +3,7 @@ namespace App\Livewire\Security; use App\Models\CloudInitScript; +use App\Rules\ValidCloudInitYaml; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -20,15 +21,19 @@ class CloudInitScriptForm extends Component public function mount(?int $scriptId = null) { - if ($scriptId) { - $this->scriptId = $scriptId; - $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); - $this->authorize('update', $cloudInitScript); + try { + if ($scriptId) { + $this->scriptId = $scriptId; + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('update', $cloudInitScript); - $this->name = $cloudInitScript->name; - $this->script = $cloudInitScript->script; - } else { - $this->authorize('create', CloudInitScript::class); + $this->name = $cloudInitScript->name; + $this->script = $cloudInitScript->script; + } else { + $this->authorize('create', CloudInitScript::class); + } + } catch (\Throwable $e) { + return handleError($e, $this); } } @@ -36,7 +41,7 @@ class CloudInitScriptForm extends Component { return [ 'name' => 'required|string|max:255', - 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml], + 'script' => ['required', 'string', new ValidCloudInitYaml], ]; } @@ -64,17 +69,29 @@ class CloudInitScriptForm extends Component 'script' => $this->script, ]); + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $cloudInitScript->id, + 'cloud_init_script_name' => $cloudInitScript->name, + ]); + $message = 'Cloud-init script updated successfully.'; } else { // Create new script $this->authorize('create', CloudInitScript::class); - CloudInitScript::create([ + $cloudInitScript = CloudInitScript::create([ 'team_id' => currentTeam()->id, 'name' => $this->name, 'script' => $this->script, ]); + auditLog('ui.cloud_init_script.created', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $cloudInitScript->id, + 'cloud_init_script_name' => $cloudInitScript->name, + ]); + $message = 'Cloud-init script created successfully.'; } diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 13bcf2caa..57b7324d3 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -36,9 +36,16 @@ class CloudInitScripts extends Component $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); $this->authorize('delete', $script); + $scriptName = $script->name; $script->delete(); $this->loadScripts(); + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + $this->dispatch('success', 'Cloud-init script deleted successfully.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 7affb1531..6d0efa15f 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -21,7 +21,11 @@ class CloudProviderTokenForm extends Component public function mount() { - $this->authorize('create', CloudProviderToken::class); + try { + $this->authorize('create', CloudProviderToken::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } protected function rules(): array @@ -50,7 +54,6 @@ class CloudProviderTokenForm extends Component $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); - ray($response); return $response->successful(); } @@ -81,6 +84,13 @@ class CloudProviderTokenForm extends Component 'name' => $this->name, ]); + auditLog('ui.cloud_token.created', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $savedToken->uuid, + 'cloud_token_name' => $savedToken->name, + 'provider' => $savedToken->provider, + ]); + $this->reset(['token', 'name']); // Dispatch event with token ID so parent components can react diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index cfef30772..dabb199ed 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -4,6 +4,7 @@ namespace App\Livewire\Security; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Http; use Livewire\Component; class CloudProviderTokens extends Component @@ -14,8 +15,12 @@ class CloudProviderTokens extends Component public function mount() { - $this->authorize('viewAny', CloudProviderToken::class); - $this->loadTokens(); + try { + $this->authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getListeners() @@ -53,6 +58,14 @@ class CloudProviderTokens extends Component } else { $this->dispatch('error', 'Unknown provider.'); } + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'valid' => $isValid ?? false, + ]); } catch (\Throwable $e) { return handleError($e, $this); } @@ -61,7 +74,7 @@ class CloudProviderTokens extends Component private function validateHetznerToken(string $token): bool { try { - $response = \Illuminate\Support\Facades\Http::withToken($token) + $response = Http::withToken($token) ->timeout(10) ->get('https://api.hetzner.cloud/v1/servers?per_page=1'); @@ -74,7 +87,7 @@ class CloudProviderTokens extends Component private function validateDigitalOceanToken(string $token): bool { try { - $response = \Illuminate\Support\Facades\Http::withToken($token) + $response = Http::withToken($token) ->timeout(10) ->get('https://api.digitalocean.com/v2/account'); @@ -98,9 +111,19 @@ class CloudProviderTokens extends Component return; } + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $tokenProvider = $token->provider; $token->delete(); $this->loadTokens(); + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $tokenUuid, + 'cloud_token_name' => $tokenName, + 'provider' => $tokenProvider, + ]); + $this->dispatch('success', 'Cloud provider token deleted successfully.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 1eb66ae3e..0362b65fa 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -21,8 +21,12 @@ class Index extends Component public function cleanupUnusedKeys() { - $this->authorize('create', PrivateKey::class); - PrivateKey::cleanupUnusedKeys(); - $this->dispatch('success', 'Unused keys have been cleaned up.'); + try { + $this->authorize('create', PrivateKey::class); + PrivateKey::cleanupUnusedKeys(); + $this->dispatch('success', 'Unused keys have been cleaned up.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index 6b22fddc6..e3232d3f3 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server\CloudProviderToken; use App\Models\CloudProviderToken; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Http; use Livewire\Component; class Show extends Component @@ -67,6 +68,16 @@ class Show extends Component $this->server->cloudProviderToken()->associate($ownedToken); $this->server->save(); + + auditLog('ui.server.cloud_token_assigned', [ + 'team_id' => currentTeam()->id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'cloud_token_uuid' => $ownedToken->uuid, + 'cloud_token_name' => $ownedToken->name, + 'provider' => $ownedToken->provider, + ]); + $this->dispatch('success', 'Hetzner token updated successfully.'); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { @@ -79,7 +90,7 @@ class Show extends Component { try { // First, validate the token itself - $response = \Illuminate\Support\Facades\Http::withHeaders([ + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); @@ -92,7 +103,7 @@ class Show extends Component // Check if this token can access the specific Hetzner server if ($this->server->hetzner_server_id) { - $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + $serverResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); @@ -123,7 +134,7 @@ class Show extends Component return; } - $response = \Illuminate\Support\Facades\Http::withHeaders([ + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); @@ -132,6 +143,16 @@ class Show extends Component } else { $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); } + + auditLog('ui.server.cloud_token_validated', [ + 'team_id' => currentTeam()->id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'valid' => $response->successful(), + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index 24f8e022e..2ab829854 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -72,12 +72,16 @@ class CloudflareTunnel extends Component public function manualCloudflareConfig() { - $this->authorize('update', $this->server); - $this->isCloudflareTunnelsEnabled = true; - $this->server->settings->is_cloudflare_tunnel = true; - $this->server->settings->save(); - $this->server->refresh(); - $this->dispatch('success', 'Cloudflare Tunnel enabled.'); + try { + $this->authorize('update', $this->server); + $this->isCloudflareTunnelsEnabled = true; + $this->server->settings->is_cloudflare_tunnel = true; + $this->server->settings->save(); + $this->server->refresh(); + $this->dispatch('success', 'Cloudflare Tunnel enabled.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function automatedCloudflareConfig() diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index f3f142646..7b5d6f9da 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -69,6 +69,11 @@ class Destinations extends Component public function scan() { + try { + $this->authorize('update', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } if ($this->server->isSwarm()) { $alreadyAddedNetworks = $this->server->swarmDockers; } else { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 4c6f31b0c..9ae065d83 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -79,14 +79,18 @@ class ByHetzner extends Component public function mount() { - $this->authorize('viewAny', CloudProviderToken::class); - $this->loadTokens(); - $this->loadSavedCloudInitScripts(); - $this->server_name = generate_random_name(); - $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + try { + $this->authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); - if ($this->private_keys->count() > 0) { - $this->private_key_id = $this->private_keys->first()->id; + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index c2d8205ef..8cd4e9640 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -102,11 +102,15 @@ class Proxy extends Component public function changeProxy() { - $this->authorize('update', $this->server); - $this->server->proxy = null; - $this->server->save(); + try { + $this->authorize('update', $this->server); + $this->server->proxy = null; + $this->server->save(); - $this->dispatch('reloadWindow'); + $this->dispatch('reloadWindow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function selectProxy($proxy_type) diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index 20d14ddc7..4ea21e4ae 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -22,33 +22,37 @@ class DynamicConfigurationNavbar extends Component public function delete(string $fileName) { - $this->authorize('update', $this->server); - $proxy_path = $this->server->proxyPath(); - $proxy_type = $this->server->proxyType(); + try { + $this->authorize('update', $this->server); + $proxy_path = $this->server->proxyPath(); + $proxy_type = $this->server->proxyType(); - // Decode filename: pipes are used to encode dots for Livewire property binding - // (e.g., 'my|service.yaml' -> 'my.service.yaml') - // This must happen BEFORE validation because validateFilenameSafe() - // rejects pipe characters through validateShellSafePath(). - $file = str_replace('|', '.', $fileName); + // Decode filename: pipes are used to encode dots for Livewire property binding + // (e.g., 'my|service.yaml' -> 'my.service.yaml') + // This must happen BEFORE validation because validateFilenameSafe() + // rejects pipe characters through validateShellSafePath(). + $file = str_replace('|', '.', $fileName); - validateFilenameSafe($file, 'proxy configuration filename'); + validateFilenameSafe($file, 'proxy configuration filename'); - if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { - $this->dispatch('error', 'Cannot delete Caddyfile.'); + if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { + $this->dispatch('error', 'Cannot delete Caddyfile.'); - return; + return; + } + + $fullPath = "{$proxy_path}/dynamic/{$file}"; + $escapedPath = escapeshellarg($fullPath); + instant_remote_process(["rm -f {$escapedPath}"], $this->server); + if ($proxy_type === 'CADDY') { + $this->server->reloadCaddy(); + } + $this->dispatch('success', 'File deleted.'); + $this->dispatch('loadDynamicConfigurations'); + $this->dispatch('refresh'); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $fullPath = "{$proxy_path}/dynamic/{$file}"; - $escapedPath = escapeshellarg($fullPath); - instant_remote_process(["rm -f {$escapedPath}"], $this->server); - if ($proxy_type === 'CADDY') { - $this->server->reloadCaddy(); - } - $this->dispatch('success', 'File deleted.'); - $this->dispatch('loadDynamicConfigurations'); - $this->dispatch('refresh'); } public function render() diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index 6ea9e7c3d..f824645aa 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -3,11 +3,14 @@ namespace App\Livewire\Server\Proxy; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class DynamicConfigurations extends Component { + use AuthorizesRequests; + public ?Server $server = null; public $parameters = []; @@ -35,6 +38,11 @@ class DynamicConfigurations extends Component public function loadDynamicConfigurations() { + try { + $this->authorize('view', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } $proxy_path = $this->server->proxyPath(); $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 3710064dc..9ea87161d 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -30,38 +30,53 @@ class Resources extends Component public function startUnmanaged($id) { - if (! ValidationPatterns::isValidContainerName($id)) { - $this->dispatch('error', 'Invalid container identifier.'); + try { + $this->authorize('update', $this->server); + if (! ValidationPatterns::isValidContainerName($id)) { + $this->dispatch('error', 'Invalid container identifier.'); - return; + return; + } + $this->server->startUnmanaged($id); + $this->dispatch('success', 'Container started.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->server->startUnmanaged($id); - $this->dispatch('success', 'Container started.'); - $this->loadUnmanagedContainers(); } public function restartUnmanaged($id) { - if (! ValidationPatterns::isValidContainerName($id)) { - $this->dispatch('error', 'Invalid container identifier.'); + try { + $this->authorize('update', $this->server); + if (! ValidationPatterns::isValidContainerName($id)) { + $this->dispatch('error', 'Invalid container identifier.'); - return; + return; + } + $this->server->restartUnmanaged($id); + $this->dispatch('success', 'Container restarted.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->server->restartUnmanaged($id); - $this->dispatch('success', 'Container restarted.'); - $this->loadUnmanagedContainers(); } public function stopUnmanaged($id) { - if (! ValidationPatterns::isValidContainerName($id)) { - $this->dispatch('error', 'Invalid container identifier.'); + try { + $this->authorize('update', $this->server); + if (! ValidationPatterns::isValidContainerName($id)) { + $this->dispatch('error', 'Invalid container identifier.'); - return; + return; + } + $this->server->stopUnmanaged($id); + $this->dispatch('success', 'Container stopped.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->server->stopUnmanaged($id); - $this->dispatch('success', 'Container stopped.'); - $this->loadUnmanagedContainers(); } public function refreshStatus() diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index b4d151424..087836da3 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -41,7 +41,11 @@ class Patches extends Component { $this->parameters = get_route_parameters(); $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); - $this->authorize('viewSecurity', $this->server); + try { + $this->authorize('viewSecurity', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function checkForUpdatesDispatch() @@ -69,14 +73,14 @@ class Patches extends Component public function updateAllPackages() { - $this->authorize('update', $this->server); - if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run "Check for updates" first.'); - - return; - } - try { + $this->authorize('update', $this->server); + if (! $this->packageManager || ! $this->osId) { + $this->dispatch('error', message: 'Run "Check for updates" first.'); + + return; + } + $activity = UpdatePackage::run( server: $this->server, packageManager: $this->packageManager, @@ -84,8 +88,8 @@ class Patches extends Component all: true ); $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); - } catch (\Exception $e) { - $this->dispatch('error', message: $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 6619e101e..1190cd59a 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -3,11 +3,14 @@ namespace App\Livewire\Server\Sentinel; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; use Livewire\Component; class Logs extends Component { + use AuthorizesRequests; + public ?Server $server = null; public array $parameters = []; @@ -19,7 +22,11 @@ class Logs extends Component $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail(); } catch (\Throwable $e) { handleError($e, $this); + + return; } + + $this->authorize('viewSentinel', $this->server); } public function render(): View diff --git a/app/Livewire/Server/Sentinel/Show.php b/app/Livewire/Server/Sentinel/Show.php index 7070a09ce..fc30994d6 100644 --- a/app/Livewire/Server/Sentinel/Show.php +++ b/app/Livewire/Server/Sentinel/Show.php @@ -3,11 +3,14 @@ namespace App\Livewire\Server\Sentinel; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public ?Server $server = null; public array $parameters = []; @@ -19,7 +22,11 @@ class Show extends Component $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail(); } catch (\Throwable $e) { handleError($e, $this); + + return; } + + $this->authorize('viewSentinel', $this->server); } public function render(): View diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 4a6e2335e..d14046ed1 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -299,18 +299,22 @@ class Show extends Component public function checkLocalhostConnection() { - $this->syncData(true); - ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); - if ($uptime) { - $this->dispatch('success', 'Server is reachable.'); - $this->server->settings->is_reachable = $this->isReachable = true; - $this->server->settings->is_usable = $this->isUsable = true; - $this->server->settings->save(); - ServerReachabilityChanged::dispatch($this->server); - } else { - $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); + try { + $this->syncData(true); + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if ($uptime) { + $this->dispatch('success', 'Server is reachable.'); + $this->server->settings->is_reachable = $this->isReachable = true; + $this->server->settings->is_usable = $this->isUsable = true; + $this->server->settings->save(); + ServerReachabilityChanged::dispatch($this->server); + } else { + $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); - return; + return; + } + } catch (\Throwable $e) { + return handleError($e, $this); } } @@ -413,6 +417,7 @@ class Show extends Component public function checkHetznerServerStatus(bool $manual = false) { try { + $this->authorize('view', $this->server); if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); @@ -480,6 +485,7 @@ class Show extends Component public function startHetznerServer() { try { + $this->authorize('update', $this->server); if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 59ca4cd36..afcc918a6 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -72,32 +72,40 @@ class ValidateAndInstall extends Component public function retry() { - $this->authorize('update', $this->server); - $this->uptime = null; - $this->supported_os_type = null; - $this->prerequisites_installed = null; - $this->docker_installed = null; - $this->docker_compose_installed = null; - $this->docker_version = null; - $this->error = null; - $this->number_of_tries = 0; - $this->init(); + try { + $this->authorize('update', $this->server); + $this->uptime = null; + $this->supported_os_type = null; + $this->prerequisites_installed = null; + $this->docker_installed = null; + $this->docker_compose_installed = null; + $this->docker_version = null; + $this->error = null; + $this->number_of_tries = 0; + $this->init(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function validateConnection() { - $this->authorize('update', $this->server); - ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); - if (! $this->uptime) { - $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; - $this->server->update([ - 'validation_logs' => $this->error, - ]); + try { + $this->authorize('update', $this->server); + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); + if (! $this->uptime) { + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); - return; + return; + } + $this->dispatch('validateOS'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->dispatch('validateOS'); } public function validateOS() diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 3a6237183..5be066c7f 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -5,11 +5,14 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Advanced extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; #[Validate('boolean')] @@ -77,6 +80,7 @@ class Advanced extends Component public function submit() { try { + $this->authorize('update', $this->settings); $this->validate(); $this->custom_dns_servers = str($this->custom_dns_servers)->replaceEnd(',', '')->trim(); @@ -146,6 +150,7 @@ class Advanced extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; @@ -178,6 +183,7 @@ class Advanced extends Component public function toggleTwoStepConfirmation($password): bool { + $this->authorize('update', $this->settings); if (! verifyPasswordConfirmation($password, $this)) { return false; } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index c2789aa91..9cfc4f6c8 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -4,12 +4,15 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; public ?Server $server = null; @@ -87,6 +90,7 @@ class Index extends Component public function instantSave($isSave = true) { + $this->authorize('update', $this->settings); $this->validate(); $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; $this->settings->public_port_min = $this->public_port_min; @@ -104,6 +108,7 @@ class Index extends Component public function confirmDomainUsage() { + $this->authorize('update', $this->settings); $this->forceSaveDomains = true; $this->showDomainConflictModal = false; $this->submit(); @@ -112,6 +117,7 @@ class Index extends Component public function submit() { try { + $this->authorize('update', $this->settings); $error_show = false; $this->resetErrorBag(); @@ -173,6 +179,7 @@ class Index extends Component public function buildHelperImage() { try { + $this->authorize('update', $this->settings); if (! isDev()) { $this->dispatch('error', 'Building helper image is only available in development mode.'); diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index a200ef689..88a8945d0 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -5,11 +5,14 @@ namespace App\Livewire\Settings; use App\Jobs\CheckForUpdatesJob; use App\Models\InstanceSettings; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Updates extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; public ?Server $server = null; @@ -41,6 +44,7 @@ class Updates extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); if ($this->settings->is_auto_update_enabled === true) { $this->validate([ 'auto_update_frequency' => ['required', 'string'], @@ -59,6 +63,7 @@ class Updates extends Component public function submit() { try { + $this->authorize('update', $this->settings); $this->resetErrorBag(); $this->validate(); @@ -91,6 +96,7 @@ class Updates extends Component public function checkManually() { + $this->authorize('update', $this->settings); CheckForUpdatesJob::dispatchSync(); $this->dispatch('updateAvailable'); $settings = instanceSettings(); diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 5336c0c9a..d5d80acbd 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -8,12 +8,15 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class SettingsBackup extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; public Server $server; @@ -77,6 +80,7 @@ class SettingsBackup extends Component public function addCoolifyDatabase() { try { + $this->authorize('update', $this->settings); $server = Server::findOrFail(0); $out = instant_remote_process(['docker inspect coolify-db'], $server); $envs = format_docker_envs_to_json($out); @@ -123,14 +127,19 @@ class SettingsBackup extends Component public function submit() { - $this->validate(); + try { + $this->authorize('update', $this->settings); + $this->validate(); - $this->database->update([ - 'name' => $this->name, - 'description' => $this->description, - 'postgres_user' => $this->postgres_user, - 'postgres_password' => $this->postgres_password, - ]); - $this->dispatch('success', 'Backup updated.'); + $this->database->update([ + 'name' => $this->name, + 'description' => $this->description, + 'postgres_user' => $this->postgres_user, + 'postgres_password' => $this->postgres_password, + ]); + $this->dispatch('success', 'Backup updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 8c0e24400..1bb9b2360 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\InstanceSettings; use App\Models\Team; use App\Notifications\TransactionalEmails\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -12,6 +13,8 @@ use Livewire\Component; class SettingsEmail extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; #[Locked] @@ -103,6 +106,7 @@ class SettingsEmail extends Component public function submit() { try { + $this->authorize('update', $this->settings); $this->resetErrorBag(); $this->syncData(true); $this->dispatch('success', 'Transactional email settings updated.'); @@ -114,6 +118,7 @@ class SettingsEmail extends Component public function instantSave(string $type) { try { + $this->authorize('update', $this->settings); $currentSmtpEnabled = $this->settings->smtp_enabled; $currentResendEnabled = $this->settings->resend_enabled; $this->resetErrorBag(); @@ -141,6 +146,7 @@ class SettingsEmail extends Component public function submitSmtp() { try { + $this->authorize('update', $this->settings); $this->validate([ 'smtpEnabled' => 'boolean', 'smtpFromAddress' => 'required|email', @@ -184,6 +190,7 @@ class SettingsEmail extends Component public function submitResend() { try { + $this->authorize('update', $this->settings); $this->validate([ 'resendEnabled' => 'boolean', 'resendApiKey' => 'required|string', @@ -214,6 +221,7 @@ class SettingsEmail extends Component public function sendTestEmail() { try { + $this->authorize('update', $this->settings); $this->validate([ 'testEmailAddress' => 'required|email', ], [ diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 6f949b716..44ea4f611 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -3,10 +3,13 @@ namespace App\Livewire; use App\Models\OauthSetting; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class SettingsOauth extends Component { + use AuthorizesRequests; + public $oauth_settings_map; protected function rules() @@ -131,6 +134,7 @@ class SettingsOauth extends Component public function instantSave(string $provider) { try { + $this->authorize('update', instanceSettings()); $this->updateOauthSettings($provider); } catch (\Exception $e) { return handleError($e, $this); @@ -139,7 +143,12 @@ class SettingsOauth extends Component public function submit() { - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); + try { + $this->authorize('update', instanceSettings()); + $this->updateOauthSettings(); + $this->dispatch('success', 'Instance settings updated successfully!'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index bfbdf9212..4bc467959 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -76,7 +76,12 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index c9f0dcd8e..6b7f26c26 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -58,9 +58,13 @@ class Show extends Component public function switch() { - $this->authorize('view', $this->project); - $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->getDevView(); + try { + $this->authorize('view', $this->project); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getDevView() @@ -70,7 +74,12 @@ class Show extends Component private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 29e21a1b7..32da83672 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -52,9 +52,13 @@ class Index extends Component public function switch() { - $this->authorize('view', $this->team); - $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->getDevView(); + try { + $this->authorize('view', $this->team); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getDevView() @@ -64,7 +68,12 @@ class Index extends Component private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 342d629cb..b83b41e9f 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -33,6 +33,8 @@ class Form extends Component public ?bool $isUsable = null; + public bool $isPasswordHiddenForMember = false; + protected function rules(): array { return [ @@ -110,6 +112,12 @@ class Form extends Component public function mount() { $this->syncData(false); + + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; + if ($this->isPasswordHiddenForMember) { + $this->key = ''; + $this->secret = ''; + } } public function testConnection() diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 0dad2d548..4f39943e4 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -4,16 +4,21 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Resources extends Component { + use AuthorizesRequests; + public S3Storage $storage; public array $selectedStorages = []; public function mount(): void { + $this->authorize('view', $this->storage); + $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) ->where('save_s3', true) ->get(); @@ -25,6 +30,8 @@ class Resources extends Component public function disableS3(int $backupId): void { + $this->authorize('update', $this->storage); + $backup = ScheduledDatabaseBackup::where('id', $backupId) ->where('s3_storage_id', $this->storage->id) ->firstOrFail(); @@ -41,6 +48,8 @@ class Resources extends Component public function moveBackup(int $backupId): void { + $this->authorize('update', $this->storage); + $backup = ScheduledDatabaseBackup::where('id', $backupId) ->where('s3_storage_id', $this->storage->id) ->firstOrFail(); @@ -62,6 +71,8 @@ class Resources extends Component return; } + $this->authorize('update', $newStorage); + $backup->update(['s3_storage_id' => $newStorage->id]); unset($this->selectedStorages[$backupId]); diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index dc5121e94..dd6640c23 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -23,7 +24,11 @@ class Show extends Component if (! $this->storage) { abort(404); } - $this->authorize('view', $this->storage); + try { + $this->authorize('view', $this->storage); + } catch (AuthorizationException) { + return $this->redirectRoute('storage.index', navigate: true); + } $this->currentRoute = request()->route()->getName(); $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count(); } diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 33eed3a6a..ea560b039 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -32,6 +32,8 @@ class Actions extends Component public bool $refundAlreadyUsed = false; + public bool $refundLatestPayment = false; + public string $billingInterval = 'monthly'; public ?string $nextBillingDate = null; @@ -100,7 +102,7 @@ class Actions extends Component return 'Invalid password.'; } - $result = (new RefundSubscription)->execute(currentTeam()); + $result = app(RefundSubscription::class)->execute(currentTeam()); if ($result['success']) { $this->dispatch('success', 'Subscription refunded successfully.'); @@ -114,12 +116,28 @@ class Actions extends Component return true; } - public function cancelImmediately(string $password): bool|string + public function cancelImmediately(string $password, array $selectedActions = []): bool|string { if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { return 'Invalid password.'; } + if (in_array('refundLatestPayment', $selectedActions, true)) { + // Eligibility is re-validated server-side inside RefundSubscription::execute() + $result = app(RefundSubscription::class)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription refunded and cancelled successfully.'); + $this->redirect(route('subscription.index'), navigate: true); + + return true; + } + + $this->dispatch('error', 'Something went wrong with the refund. Please contact us.'); + + return true; + } + $team = currentTeam(); $subscription = $team->subscription; @@ -130,7 +148,7 @@ class Actions extends Component } try { - $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $stripe->subscriptions->cancel($subscription->stripe_subscription_id); $subscription->update([ diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index ac37cca05..022f6fdee 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -5,6 +5,7 @@ namespace App\Livewire\Subscription; use App\Models\InstanceSettings; use App\Providers\RouteServiceProvider; use Livewire\Component; +use Stripe\StripeClient; class Index extends Component { @@ -52,7 +53,7 @@ class Index extends Component { try { $subscription = currentTeam()->subscription; - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripe = app(StripeClient::class); $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id); if ($customer) { $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index fc5b13374..28d6440a9 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -5,6 +5,7 @@ namespace App\Livewire\Tags; use App\Http\Controllers\Api\DeployController; use App\Models\ApplicationDeploymentQueue; use App\Models\Tag; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -13,6 +14,8 @@ use Livewire\Component; #[Title('Tags | Coolify')] class Show extends Component { + use AuthorizesRequests; + #[Locked] public ?string $tagName = null; @@ -73,6 +76,12 @@ class Show extends Component public function redeployAll() { try { + $this->applications->each(function ($resource) { + $this->authorize('deploy', $resource); + }); + $this->services->each(function ($resource) { + $this->authorize('deploy', $resource); + }); $message = collect([]); $this->applications->each(function ($resource) use ($message) { $deploy = new DeployController; diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 09878f27b..b9cb3a43b 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -25,6 +25,9 @@ class AdminView extends Component public function submitSearch() { + if (! isInstanceAdmin()) { + return; + } if ($this->search !== '') { $this->users = User::where(function ($query) { $query->where('name', 'like', "%{$this->search}%") @@ -39,6 +42,9 @@ class AdminView extends Component public function getUsers() { + if (! isInstanceAdmin()) { + return; + } $users = User::where('id', '!=', auth()->id())->get(); if ($users->count() > $this->number_of_users_to_show) { $this->lots_of_users = true; diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 8a943e6b6..140d9f5cc 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -7,6 +7,7 @@ use App\Models\TeamInvitation; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -95,23 +96,31 @@ class Index extends Component public function delete() { - $currentTeam = currentTeam(); - $this->authorize('delete', $currentTeam); - $currentTeam->delete(); + try { + $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); + $currentTeam->members->each(function ($user) use ($currentTeam) { + if ($user->id === Auth::id()) { + return; + } + $user->teams()->detach($currentTeam); + $session = DB::table('sessions')->where('user_id', $user->id)->first(); + if ($session) { + DB::table('sessions')->where('id', $session->id)->delete(); + } + }); - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); + // Clear stale cache before deleting so refreshSession doesn't resolve the deleted team + Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); + $currentTeam->delete(); - refreshSession(); + // Switch to the user's next available team + $newTeam = Auth::user()->teams()->first(); + refreshSession($newTeam); - return redirect()->route('team.index'); + return redirect()->route('team.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index fb30961e9..5b040db71 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Livewire\Component; -use Visus\Cuid2\Cuid2; class InviteLink extends Component { @@ -61,7 +60,7 @@ class InviteLink extends Component if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } - $uuid = (string) new Cuid2(32); + $uuid = new_public_id(32); $link = url('/').config('constants.invitation.link.base_url').$uuid; $user = User::whereEmail($this->email)->first(); diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index 1b8701d94..0ccb06a08 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -62,6 +62,9 @@ class Upgrade extends Component public function upgrade() { try { + if (! isInstanceAdmin()) { + abort(403); + } if ($this->updateInProgress) { return; } diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index f6d82453a..8e0ae0467 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -7,15 +7,19 @@ use Laravel\Mcp\Response; trait ResolvesTeam { - protected function ensureAbility(Request $request, string $ability = 'read'): ?Response + protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response { $user = $request->user(); if (! $user) { + $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'unauthenticated']); + return Response::error('Unauthenticated.'); } $token = $user->currentAccessToken(); if (! $token) { + $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'invalid_token']); + return Response::error('Invalid token.'); } @@ -23,6 +27,11 @@ trait ResolvesTeam return null; } + $this->auditMcpTool($request, $tool, 'denied', [ + 'reason' => 'missing_ability', + 'required_ability' => $ability, + ]); + return Response::error("Missing required permissions: {$ability}"); } @@ -38,4 +47,28 @@ trait ResolvesTeam return (int) $teamId; } + + protected function mcpSuccess(Request $request, Response $response, array $context = []): Response + { + $this->auditMcpTool($request, $this->name ?? null, 'success', $context); + + return $response; + } + + protected function mcpError(Request $request, string $message, array $context = []): Response + { + $this->auditMcpTool($request, $this->name ?? null, 'error', $context + ['reason' => $message]); + + return Response::error($message); + } + + protected function auditMcpTool(Request $request, ?string $tool, string $outcome, array $context = []): void + { + auditLog('mcp.tool.called', [ + 'tool' => $tool ?: 'unknown', + 'team_id' => $this->resolveTeamId($request), + 'outcome' => $outcome, + ...$context, + ]); + } } diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index aff7e3f76..2b2d33d60 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -13,13 +13,14 @@ use App\Mcp\Tools\ListProjects; use App\Mcp\Tools\ListServers; use App\Mcp\Tools\ListServices; use Laravel\Mcp\Server; -use Laravel\Mcp\Server\Attributes\Instructions; -use Laravel\Mcp\Server\Attributes\Name; -use Laravel\Mcp\Server\Attributes\Version; -#[Name('Coolify')] -#[Version('0.1.0')] -#[Instructions(<<<'MD' +class CoolifyServer extends Server +{ + protected string $name = 'Coolify'; + + protected string $version = '0.1.0'; + + protected string $instructions = <<<'MD' Read-only MCP server for Coolify, scoped to the authenticated team token. Recommended workflow: @@ -28,9 +29,8 @@ Recommended workflow: 3. get_server / get_application / get_database / get_service — full details for a single UUID. Every response is `{ data, _actions?, _pagination? }`. `_actions` suggests the next tool + args; `_pagination.next` is the args to call again for the next page. -MD)] -class CoolifyServer extends Server -{ +MD; + protected array $tools = [ GetInfrastructureOverview::class, ListServers::class, diff --git a/app/Mcp/Tools/GetApplication.php b/app/Mcp/Tools/GetApplication.php index f7ac8db77..1d2f9f014 100644 --- a/app/Mcp/Tools/GetApplication.php +++ b/app/Mcp/Tools/GetApplication.php @@ -8,36 +8,36 @@ use App\Models\Application; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_application')] -#[Description('Get full details for a single application by UUID.')] class GetApplication extends Tool { + protected string $name = 'get_application'; + + protected string $description = 'Get full details for a single application by UUID.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); if (! $application) { - return Response::error("Application [{$uuid}] not found."); + return $this->mcpError($request, "Application [{$uuid}] not found.", ['resource_uuid' => $uuid]); } // Drop relations that the server_status accessor lazy-loads — they @@ -45,10 +45,10 @@ class GetApplication extends Tool $application->setRelations([]); $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $this->scrubSensitive($application->toArray()), $this->actionsForApplication($uuid, $application->status), - ); + ), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php index 4eee9c961..c5d62e3a0 100644 --- a/app/Mcp/Tools/GetDatabase.php +++ b/app/Mcp/Tools/GetDatabase.php @@ -7,46 +7,46 @@ use App\Mcp\Concerns\ResolvesTeam; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_database')] -#[Description('Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.')] class GetDatabase extends Tool { + protected string $name = 'get_database'; + + protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId); if (! $database) { - return Response::error("Database [{$uuid}] not found."); + return $this->mcpError($request, "Database [{$uuid}] not found.", ['resource_uuid' => $uuid]); } // Drop relations so deep server/destination data doesn't leak. $database->setRelations([]); $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $this->scrubSensitive($database->toArray()), $this->actionsForDatabase($uuid, $database->status ?? null), - ); + ), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index 06e91ff57..6fcafa316 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -9,26 +9,26 @@ use App\Models\Server; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_infrastructure_overview')] -#[Description('High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.')] class GetInfrastructureOverview extends Tool { + protected string $name = 'get_infrastructure_overview'; + + protected string $description = 'High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $servers = Server::whereTeamId($teamId) @@ -72,7 +72,7 @@ class GetInfrastructureOverview extends Tool ]; } - return $this->respond([ + return $this->mcpSuccess($request, $this->respond([ 'coolify_version' => config('constants.coolify.version'), 'servers' => $servers, 'projects' => $projectSummaries, @@ -83,7 +83,7 @@ class GetInfrastructureOverview extends Tool 'services' => $serviceCount, 'databases' => $databaseCount, ], - ]); + ])); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php index fc3e72f14..771aa7d36 100644 --- a/app/Mcp/Tools/GetServer.php +++ b/app/Mcp/Tools/GetServer.php @@ -8,36 +8,36 @@ use App\Models\Server; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_server')] -#[Description('Get full details for a single server by UUID.')] class GetServer extends Tool { + protected string $name = 'get_server'; + + protected string $description = 'Get full details for a single server by UUID.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first(); if (! $server) { - return Response::error("Server [{$uuid}] not found."); + return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]); } $data = $this->scrubSensitive($server->toArray()); @@ -45,7 +45,7 @@ class GetServer extends Tool $data['is_usable'] = $server->settings?->is_usable; $data['connection_timeout'] = $server->settings?->connection_timeout; - return $this->respond($data, $this->actionsForServer($uuid)); + return $this->mcpSuccess($request, $this->respond($data, $this->actionsForServer($uuid)), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php index 475958272..ad14ddb49 100644 --- a/app/Mcp/Tools/GetService.php +++ b/app/Mcp/Tools/GetService.php @@ -8,31 +8,31 @@ use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_service')] -#[Description('Get full details for a single service (multi-container stack) by UUID.')] class GetService extends Tool { + protected string $name = 'get_service'; + + protected string $description = 'Get full details for a single service (multi-container stack) by UUID.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $service = Service::whereRelation('environment.project.team', 'id', $teamId) @@ -40,16 +40,16 @@ class GetService extends Tool ->first(); if (! $service) { - return Response::error("Service [{$uuid}] not found."); + return $this->mcpError($request, "Service [{$uuid}] not found.", ['resource_uuid' => $uuid]); } $service->setRelations([]); $service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $this->scrubSensitive($service->toArray()), $this->actionsForService($uuid, $service->status ?? null), - ); + ), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index 815edd61a..bf31131b2 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -8,31 +8,31 @@ use App\Models\Application; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_applications')] -#[Description('List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.')] class ListApplications extends Tool { + protected string $name = 'list_applications'; + + protected string $description = 'List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $tagName = $request->get('tag'); if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) { - return Response::error('tag argument must be a non-empty string.'); + return $this->mcpError($request, 'tag argument must be a non-empty string.'); } $args = $this->paginationArgs($request); @@ -59,11 +59,11 @@ class ListApplications extends Tool $extra = $tagName ? ['tag' => $tagName] : []; - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_applications', $args, $total, $extra), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php index 7eb1fde00..98de6ecee 100644 --- a/app/Mcp/Tools/ListDatabases.php +++ b/app/Mcp/Tools/ListDatabases.php @@ -8,26 +8,26 @@ use App\Models\Project; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_databases')] -#[Description('List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.')] class ListDatabases extends Tool { + protected string $name = 'list_databases'; + + protected string $description = 'List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -52,11 +52,11 @@ class ListDatabases extends Tool ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_databases', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php index 9ce1576b9..0a6de7f60 100644 --- a/app/Mcp/Tools/ListProjects.php +++ b/app/Mcp/Tools/ListProjects.php @@ -8,26 +8,26 @@ use App\Models\Project; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_projects')] -#[Description('List projects owned by the authenticated team. Returns summary (uuid, name, description).')] class ListProjects extends Tool { + protected string $name = 'list_projects'; + + protected string $description = 'List projects owned by the authenticated team. Returns summary (uuid, name, description).'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -49,11 +49,11 @@ class ListProjects extends Tool ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_projects', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php index 20250c454..ed10afc93 100644 --- a/app/Mcp/Tools/ListServers.php +++ b/app/Mcp/Tools/ListServers.php @@ -8,26 +8,26 @@ use App\Models\Server; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_servers')] -#[Description('List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.')] class ListServers extends Tool { + protected string $name = 'list_servers'; + + protected string $description = 'List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -50,11 +50,11 @@ class ListServers extends Tool ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_servers', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php index b0bff4fad..3a0ea158a 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -8,26 +8,26 @@ use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_services')] -#[Description('List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.')] class ListServices extends Tool { + protected string $name = 'list_services'; + + protected string $description = 'List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -49,11 +49,11 @@ class ListServices extends Tool ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_services', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Models/Application.php b/app/Models/Application.php index b2f852f15..2c408483e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -23,7 +23,6 @@ use RuntimeException; use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; #[OA\Schema( description: 'Application model', @@ -1339,7 +1338,7 @@ class Application extends BaseModel { try { ['commands' => $lsRemoteCommand] = $this->generateGitLsRemoteCommands(deployment_uuid: $deployment_uuid, exec_in_docker: false); - instant_remote_process([$lsRemoteCommand], $this->destination->server, true); + instant_remote_process([$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true); return [ 'is_accessible' => true, @@ -1391,13 +1390,13 @@ class Application extends BaseModel } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1414,29 +1413,16 @@ class Application extends BaseModel $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$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 {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1448,13 +1434,13 @@ class Application extends BaseModel $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1473,29 +1459,16 @@ class Application extends BaseModel $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$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 {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1507,19 +1480,60 @@ class Application extends BaseModel $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command))); } else { - $commands->push($base_command); + $commands->push($this->gitCommand($base_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; } } + private function gitCommand(string $command): array + { + return [ + 'command' => $command, + 'hidden' => true, + ]; + } + + private function gitCommandsAsShellCommand(Collection|array|string $commands): string + { + if (is_string($commands)) { + return $commands; + } + + return collect($commands) + ->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command) + ->implode(' && '); + } + + private function gitSshKeySetupCommands(string $deploymentUuid, string $privateKey, bool $execInDocker): Collection + { + $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deploymentUuid}"; + $commands = collect([]); + + if (! $execInDocker) { + $commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation}' EXIT")); + } + + $commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, 'mkdir -p /root/.ssh') : 'mkdir -p /root/.ssh')); + $commands->push([ + 'command' => $execInDocker + ? executeInDocker($deploymentUuid, "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null") + : "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", + 'hidden' => true, + 'skip_command_log' => true, + ]); + $commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, "chmod 600 {$customSshKeyLocation}") : "chmod 600 {$customSshKeyLocation}")); + + return $commands; + } + private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string { return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1'); @@ -1591,9 +1605,9 @@ class Application extends BaseModel $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } } else { $github_access_token = generateGithubInstallationToken($this->source); @@ -1620,9 +1634,9 @@ class Application extends BaseModel $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions); } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } } if ($pull_request_id !== 0) { @@ -1632,14 +1646,14 @@ class Application extends BaseModel $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"))); } else { - $commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"); + $commands->push($this->gitCommand("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); } } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1662,39 +1676,26 @@ class Application extends BaseModel } else { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand); } - if ($exec_in_docker) { - $commands = collect([ - executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); 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'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand); } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1711,13 +1712,13 @@ class Application extends BaseModel $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1739,55 +1740,42 @@ class Application extends BaseModel } else { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand); } - if ($exec_in_docker) { - $commands = collect([ - executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), - executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), - executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"), - ]); - } else { - $commands = collect([ - "trap 'rm -f {$customSshKeyLocation}' EXIT", - 'mkdir -p /root/.ssh', - "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null", - "chmod 600 {$customSshKeyLocation}", - ]); - } + $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $commands, 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1808,37 +1796,37 @@ class Application extends BaseModel if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { - $commands->push("echo 'Checking out $branch'"); + $commands->push($this->gitCommand("echo 'Checking out $branch'")); } $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions); } } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { - $commands->push($git_clone_command); + $commands->push($this->gitCommand($git_clone_command)); } return [ - 'commands' => $commands->implode(' && '), + 'commands' => $this->gitCommandsAsShellCommand($commands), 'branch' => $branch, 'fullRepoUrl' => $fullRepoUrl, ]; @@ -1925,8 +1913,9 @@ class Application extends BaseModel if ($isInit && $this->docker_compose_raw) { return; } - $uuid = new Cuid2; + $uuid = new_public_id(); ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout'); + $cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand); $cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand); $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 9159fd0d8..6e4b696d5 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; class ApplicationPreview extends BaseModel { @@ -111,7 +110,7 @@ class ApplicationPreview extends BaseModel $port = $portInt !== null ? ':'.$portInt : ''; $urlPath = $url->getPath(); $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); @@ -173,7 +172,7 @@ class ApplicationPreview extends BaseModel $port = $portInt !== null ? ':'.$portInt : ''; $urlPath = $url->getPath(); $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index 727abed5f..d657fbec4 100644 --- a/app/Models/BaseModel.php +++ b/app/Models/BaseModel.php @@ -4,7 +4,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; -use Visus\Cuid2\Cuid2; abstract class BaseModel extends Model { @@ -15,7 +14,7 @@ abstract class BaseModel extends Model static::creating(function (Model $model) { // Generate a UUID if one isn't set if (! $model->uuid) { - $model->uuid = (string) new Cuid2; + $model->uuid = new_public_id(); } }); } diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 026d11fba..35452553b 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,8 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; + class CloudProviderToken extends BaseModel { + use HasFactory; + protected $fillable = [ 'team_id', 'provider', diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 1521678f3..bf42f21c7 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -30,7 +31,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use HasSafeStringAttribute, WithRateLimiting; + use HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 632787a07..b47e7cf04 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -6,7 +6,6 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; -use Visus\Cuid2\Cuid2; #[OA\Schema( description: 'Project model', @@ -59,7 +58,7 @@ class Project extends BaseModel Environment::create([ 'name' => 'production', 'project_id' => $project->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); }); static::deleting(function ($project) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 190ee6e67..3b344dfff 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -176,21 +176,25 @@ class S3Storage extends BaseModel $exception = $this->toUserFriendlyConnectionException($e); $this->is_usable = false; if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) { - $mail = new MailMessage; - $mail->subject('Coolify: S3 Storage Connection Error'); - $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); + try { + $mail = new MailMessage; + $mail->subject('Coolify: S3 Storage Connection Error'); + $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); - // Load the team with its members and their roles explicitly - $team = $this->team()->with(['members' => function ($query) { - $query->withPivot('role'); - }])->first(); + // Load the team with its members and their roles explicitly + $team = $this->team()->with(['members' => function ($query) { + $query->withPivot('role'); + }])->first(); - // Get admins directly from the pivot relationship for this specific team - $users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']); - foreach ($users as $user) { - send_user_an_email($mail, $user->email); + // Get admins directly from the pivot relationship for this specific team + $users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']); + foreach ($users as $user) { + send_user_an_email($mail, $user->email); + } + $this->unusable_email_sent = true; + } catch (\Throwable $emailException) { + \Log::warning('Failed to send S3 connection error notification: '.$emailException->getMessage()); } - $this->unusable_email_sent = true; } throw $exception; diff --git a/app/Models/Server.php b/app/Models/Server.php index 2b7bbac55..0102b327e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -37,7 +37,6 @@ use Spatie\SchemalessAttributes\SchemalessAttributesTrait; use Spatie\Url\Url; use Stevebauman\Purify\Facades\Purify; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; /** * @property array{ @@ -1041,7 +1040,7 @@ $schema://$host { { $attributes = [ 'name' => 'coolify', - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'network' => 'coolify', 'server_id' => $this->id, ]; diff --git a/app/Models/Service.php b/app/Models/Service.php index cc8074b74..72b467657 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -16,7 +16,6 @@ use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; #[OA\Schema( description: 'Service model', @@ -71,7 +70,7 @@ class Service extends BaseModel { static::creating(function ($service) { if (blank($service->name)) { - $service->name = 'service-'.(new Cuid2); + $service->name = 'service-'.new_public_id(); } }); static::created(function ($service) { @@ -626,7 +625,7 @@ class Service extends BaseModel } $fields->put('Unleash', $data->toArray()); break; - case $image->contains('grafana'): + case $this->isGrafanaImage($image->toString()): $data = collect([]); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first(); $data = $data->merge([ @@ -1380,6 +1379,15 @@ class Service extends BaseModel return $fields; } + private function isGrafanaImage(string $image): bool + { + return in_array($image, [ + 'grafana/grafana', + 'grafana/grafana-oss', + 'grafana/grafana-enterprise', + ], true); + } + public function saveExtraFields($fields) { foreach ($fields as $field) { @@ -1555,7 +1563,7 @@ class Service extends BaseModel "cd $workdir", ], $this->server); - $filename = new Cuid2.'-docker-compose.yml'; + $filename = new_public_id().'-docker-compose.yml'; Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose); $path = Storage::path("tmp/{$filename}"); instant_scp($path, "{$workdir}/docker-compose.yml", $this->server); diff --git a/app/Models/Team.php b/app/Models/Team.php index f0a50cf69..23e2badb3 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -66,7 +66,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen $team->webhookNotificationSettings()->create(); }); - static::saving(function ($team) { + static::updating(function ($team) { if (auth()->user()?->isMember()) { throw new \Exception('You are not allowed to update this team.'); } diff --git a/app/Models/User.php b/app/Models/User.php index 9cbe88835..b59b553d9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -349,6 +349,11 @@ class User extends Authenticatable implements SendsEmail { $sessionTeamId = data_get(session('currentTeam'), 'id'); + // Fallback for stateless API requests: resolve team from Sanctum token + if (is_null($sessionTeamId) && $this->currentAccessToken()) { + $sessionTeamId = data_get($this->currentAccessToken(), 'team_id'); + } + if (is_null($sessionTeamId)) { return null; } diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index 761227118..ba9bade01 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -12,11 +12,6 @@ class ApiTokenPolicy */ public function viewAny(User $user): bool { - // Authorization temporarily disabled - /* - // Users can view their own API tokens - return true; - */ return true; } @@ -25,12 +20,7 @@ class ApiTokenPolicy */ public function view(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only view their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -38,11 +28,6 @@ class ApiTokenPolicy */ public function create(User $user): bool { - // Authorization temporarily disabled - /* - // All authenticated users can create their own API tokens - return true; - */ return true; } @@ -51,12 +36,7 @@ class ApiTokenPolicy */ public function update(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only update their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -64,12 +44,7 @@ class ApiTokenPolicy */ public function delete(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only delete their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -77,11 +52,6 @@ class ApiTokenPolicy */ public function manage(User $user): bool { - // Authorization temporarily disabled - /* - // All authenticated users can manage their own API tokens - return true; - */ return true; } @@ -90,7 +60,6 @@ class ApiTokenPolicy */ public function useRootPermissions(User $user): bool { - // Only admins and owners can use root permissions return $user->isAdmin() || $user->isOwner(); } @@ -99,11 +68,22 @@ class ApiTokenPolicy */ public function useWritePermissions(User $user): bool { - // Authorization temporarily disabled - /* - // Only admins and owners can use write permissions return $user->isAdmin() || $user->isOwner(); - */ - return true; + } + + /** + * Determine whether the user can use deploy permissions for API tokens. + */ + public function useDeployPermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner(); + } + + /** + * Determine whether the user can use read:sensitive permissions for API tokens. + */ + public function useSensitivePermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner(); } } diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index d64a436ad..1a1290b55 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -13,10 +13,6 @@ class ApplicationPolicy */ public function viewAny(User $user): bool { - // Authorization temporarily disabled - /* - return true; - */ return true; } @@ -25,11 +21,9 @@ class ApplicationPolicy */ public function view(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return true; - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -37,15 +31,7 @@ class ApplicationPolicy */ public function create(User $user): bool { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { - return true; - } - - return false; - */ - return true; + return $user->isAdmin(); } /** @@ -53,15 +39,17 @@ class ApplicationPolicy */ public function update(User $user, Application $application): Response { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { + $teamId = $this->getTeamId($application); + + if ($teamId === null) { + return Response::deny('Application team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { return Response::allow(); } - return Response::deny('As a member, you cannot update this application.

You need at least admin or owner permissions.'); - */ - return Response::allow(); + return Response::deny('You need at least admin or owner permissions to update this application.'); } /** @@ -69,15 +57,9 @@ class ApplicationPolicy */ public function delete(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { - return true; - } + $teamId = $this->getTeamId($application); - return false; - */ - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -85,11 +67,7 @@ class ApplicationPolicy */ public function restore(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return true; - */ - return true; + return false; } /** @@ -97,11 +75,25 @@ class ApplicationPolicy */ public function forceDelete(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + return false; + } + + /** + * Determine whether the user can upload a backup archive for this application. + */ + public function uploadBackup(User $user, Application $application): Response + { + $teamId = $this->getTeamId($application); + + if ($teamId === null) { + return Response::deny('Application team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to upload backups for this application.'); } /** @@ -109,11 +101,9 @@ class ApplicationPolicy */ public function deploy(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -121,11 +111,9 @@ class ApplicationPolicy */ public function manageDeployments(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -133,11 +121,9 @@ class ApplicationPolicy */ public function manageEnvironment(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -145,10 +131,11 @@ class ApplicationPolicy */ public function cleanupDeploymentQueue(User $user): bool { - // Authorization temporarily disabled - /* return $user->isAdmin(); - */ - return true; + } + + private function getTeamId(Application $application): ?int + { + return $application->team()?->id; } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index 4d371cc38..f3c13acd9 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -21,8 +21,9 @@ class ApplicationPreviewPolicy */ public function view(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -30,21 +31,25 @@ class ApplicationPreviewPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, ApplicationPreview $applicationPreview) + public function update(User $user, ApplicationPreview $applicationPreview): Response { - // if ($user->isAdmin()) { - // return Response::allow(); - // } + $teamId = $this->getTeamId($applicationPreview); - // return Response::deny('As a member, you cannot update this preview.

You need at least admin or owner permissions.'); - return true; + if ($teamId === null) { + return Response::deny('Application preview team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to update this preview.'); } /** @@ -52,8 +57,9 @@ class ApplicationPreviewPolicy */ public function delete(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -61,8 +67,7 @@ class ApplicationPreviewPolicy */ public function restore(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + return false; } /** @@ -70,8 +75,7 @@ class ApplicationPreviewPolicy */ public function forceDelete(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + return false; } /** @@ -79,8 +83,9 @@ class ApplicationPreviewPolicy */ public function deploy(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -88,7 +93,13 @@ class ApplicationPreviewPolicy */ public function manageDeployments(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(ApplicationPreview $applicationPreview): ?int + { + return $applicationPreview->application?->team()?->id; } } diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php index 848dc9aee..be2137cb8 100644 --- a/app/Policies/ApplicationSettingPolicy.php +++ b/app/Policies/ApplicationSettingPolicy.php @@ -20,8 +20,9 @@ class ApplicationSettingPolicy */ public function view(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,8 +30,7 @@ class ApplicationSettingPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +38,9 @@ class ApplicationSettingPolicy */ public function update(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -47,8 +48,9 @@ class ApplicationSettingPolicy */ public function delete(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -56,8 +58,7 @@ class ApplicationSettingPolicy */ public function restore(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + return false; } /** @@ -65,7 +66,11 @@ class ApplicationSettingPolicy */ public function forceDelete(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + return false; + } + + private function getTeamId(ApplicationSetting $applicationSetting): ?int + { + return $applicationSetting->application?->team()?->id; } } diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index f8e8af637..4217432b5 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -20,8 +20,9 @@ class DatabasePolicy */ public function view(User $user, $database): bool { - // return $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,21 +30,25 @@ class DatabasePolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, $database) + public function update(User $user, $database): Response { - // if ($user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id)) { - // return Response::allow(); - // } + $teamId = $this->getTeamId($database); - // return Response::deny('As a member, you cannot update this database.

You need at least admin or owner permissions.'); - return true; + if ($teamId === null) { + return Response::deny('Database team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to update this database.'); } /** @@ -51,8 +56,9 @@ class DatabasePolicy */ public function delete(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -60,8 +66,7 @@ class DatabasePolicy */ public function restore(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + return false; } /** @@ -69,8 +74,7 @@ class DatabasePolicy */ public function forceDelete(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + return false; } /** @@ -78,8 +82,27 @@ class DatabasePolicy */ public function manage(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + /** + * Determine whether the user can upload a backup archive for this database. + */ + public function uploadBackup(User $user, $database): Response + { + $teamId = $this->getTeamId($database); + + if ($teamId === null) { + return Response::deny('Database team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to upload backups for this database.'); } /** @@ -87,8 +110,9 @@ class DatabasePolicy */ public function manageBackups(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -96,7 +120,22 @@ class DatabasePolicy */ public function manageEnvironment(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId($database): ?int + { + // Instance-level databases (e.g., coolify-db) belong to root team + if (isset($database->id) && $database->id === 0) { + return 0; + } + + if (method_exists($database, 'team')) { + return $database->team()?->id; + } + + return null; } } diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php index 7199abb25..e400ec903 100644 --- a/app/Policies/EnvironmentPolicy.php +++ b/app/Policies/EnvironmentPolicy.php @@ -20,8 +20,9 @@ class EnvironmentPolicy */ public function view(User $user, Environment $environment): bool { - // return $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,8 +30,7 @@ class EnvironmentPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +38,9 @@ class EnvironmentPolicy */ public function update(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -47,8 +48,9 @@ class EnvironmentPolicy */ public function delete(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -56,8 +58,7 @@ class EnvironmentPolicy */ public function restore(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + return false; } /** @@ -65,7 +66,11 @@ class EnvironmentPolicy */ public function forceDelete(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + return false; + } + + private function getTeamId(Environment $environment): ?int + { + return $environment->project?->team_id; } } diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php index 21e2ea443..dd0f58918 100644 --- a/app/Policies/EnvironmentVariablePolicy.php +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -20,7 +20,9 @@ class EnvironmentVariablePolicy */ public function view(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -28,7 +30,7 @@ class EnvironmentVariablePolicy */ public function create(User $user): bool { - return true; + return $user->isAdmin(); } /** @@ -36,7 +38,9 @@ class EnvironmentVariablePolicy */ public function update(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -44,7 +48,9 @@ class EnvironmentVariablePolicy */ public function delete(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -52,7 +58,7 @@ class EnvironmentVariablePolicy */ public function restore(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + return false; } /** @@ -60,7 +66,7 @@ class EnvironmentVariablePolicy */ public function forceDelete(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + return false; } /** @@ -68,6 +74,19 @@ class EnvironmentVariablePolicy */ public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(EnvironmentVariable $environmentVariable): ?int + { + $resource = $environmentVariable->resourceable; + + if (! $resource || ! method_exists($resource, 'team')) { + return null; + } + + return $resource->team()?->id; } } diff --git a/app/Policies/GithubAppPolicy.php b/app/Policies/GithubAppPolicy.php index 56bec7032..79dd79838 100644 --- a/app/Policies/GithubAppPolicy.php +++ b/app/Policies/GithubAppPolicy.php @@ -20,8 +20,11 @@ class GithubAppPolicy */ public function view(User $user, GithubApp $githubApp): bool { - // return $user->teams->contains('id', $githubApp->team_id) || $githubApp->is_system_wide; - return true; + if ($githubApp->is_system_wide) { + return true; + } + + return $user->teams->contains('id', $githubApp->team_id); } /** @@ -29,8 +32,7 @@ class GithubAppPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -39,12 +41,10 @@ class GithubAppPolicy public function update(User $user, GithubApp $githubApp): bool { if ($githubApp->is_system_wide) { - // return $user->isAdmin(); - return true; + return $user->canAccessSystemResources(); } - // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); - return true; + return $user->isAdminOfTeam($githubApp->team_id); } /** @@ -53,12 +53,10 @@ class GithubAppPolicy public function delete(User $user, GithubApp $githubApp): bool { if ($githubApp->is_system_wide) { - // return $user->isAdmin(); - return true; + return $user->canAccessSystemResources(); } - // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); - return true; + return $user->isAdminOfTeam($githubApp->team_id); } /** diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php index 4f3be431d..e8764bf13 100644 --- a/app/Policies/NotificationPolicy.php +++ b/app/Policies/NotificationPolicy.php @@ -12,13 +12,11 @@ class NotificationPolicy */ public function view(User $user, Model $notificationSettings): bool { - // Check if the notification settings belong to the user's current team if (! $notificationSettings->team) { return false; } - // return $user->teams()->where('teams.id', $notificationSettings->team->id)->exists(); - return true; + return $user->teams->contains('id', $notificationSettings->team->id); } /** @@ -26,14 +24,13 @@ class NotificationPolicy */ public function update(User $user, Model $notificationSettings): bool { - // Check if the notification settings belong to the user's current team if (! $notificationSettings->team) { return false; } - // Only owners and admins can update notification settings - // return $user->isAdmin() || $user->isOwner(); - return true; + $teamId = $notificationSettings->team->id; + + return $user->isAdminOfTeam($teamId); } /** @@ -41,8 +38,7 @@ class NotificationPolicy */ public function manage(User $user, Model $notificationSettings): bool { - // return $this->update($user, $notificationSettings); - return true; + return $this->update($user, $notificationSettings); } /** @@ -50,7 +46,6 @@ class NotificationPolicy */ public function sendTest(User $user, Model $notificationSettings): bool { - // return $this->update($user, $notificationSettings); - return true; + return $this->update($user, $notificationSettings); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index e188c293f..9d65b9130 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -20,8 +20,7 @@ class ProjectPolicy */ public function view(User $user, Project $project): bool { - // return $user->teams->contains('id', $project->team_id); - return true; + return $user->teams->contains('id', $project->team_id); } /** @@ -29,8 +28,7 @@ class ProjectPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +36,7 @@ class ProjectPolicy */ public function update(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return $user->isAdminOfTeam($project->team_id); } /** @@ -47,8 +44,7 @@ class ProjectPolicy */ public function delete(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return $user->isAdminOfTeam($project->team_id); } /** @@ -56,8 +52,7 @@ class ProjectPolicy */ public function restore(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return false; } /** @@ -65,7 +60,6 @@ class ProjectPolicy */ public function forceDelete(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return false; } } diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index 9ed2b66ab..a7a855402 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -38,8 +38,7 @@ class ResourceCreatePolicy */ public function createAny(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -51,8 +50,7 @@ class ResourceCreatePolicy return false; } - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** diff --git a/app/Policies/S3StoragePolicy.php b/app/Policies/S3StoragePolicy.php index 982c7c523..81cbd164f 100644 --- a/app/Policies/S3StoragePolicy.php +++ b/app/Policies/S3StoragePolicy.php @@ -36,8 +36,7 @@ class S3StoragePolicy */ public function update(User $user, S3Storage $storage): bool { - // return $user->teams->contains('id', $storage->team_id) && $user->isAdmin(); - return $user->teams->contains('id', $storage->team_id); + return $user->teams->contains('id', $storage->team_id) && $user->isAdminOfTeam($storage->team_id); } /** @@ -45,8 +44,7 @@ class S3StoragePolicy */ public function delete(User $user, S3Storage $storage): bool { - // return $user->teams->contains('id', $storage->team_id) && $user->isAdmin(); - return $user->teams->contains('id', $storage->team_id); + return $user->teams->contains('id', $storage->team_id) && $user->isAdminOfTeam($storage->team_id); } /** @@ -70,6 +68,6 @@ class S3StoragePolicy */ public function validateConnection(User $user, S3Storage $storage): bool { - return $user->teams->contains('id', $storage->team_id); + return $user->teams->contains('id', $storage->team_id) && $user->isAdminOfTeam($storage->team_id); } } diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 6d2396a7d..c1369ce67 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -28,8 +28,7 @@ class ServerPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,8 +36,7 @@ class ServerPolicy */ public function update(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -46,8 +44,7 @@ class ServerPolicy */ public function delete(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -71,8 +68,7 @@ class ServerPolicy */ public function manageProxy(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -80,8 +76,15 @@ class ServerPolicy */ public function manageSentinel(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); + } + + /** + * Determine whether the user can view Sentinel configuration and logs. + */ + public function viewSentinel(User $user, Server $server): bool + { + return $user->isAdminOfTeam($server->team_id); } /** @@ -89,8 +92,7 @@ class ServerPolicy */ public function manageCaCertificate(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -98,7 +100,6 @@ class ServerPolicy */ public function viewSecurity(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } } diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index af380a90f..c730ab0c6 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -21,8 +21,7 @@ class ServiceApplicationPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -30,8 +29,7 @@ class ServiceApplicationPolicy */ public function update(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return Gate::allows('update', $serviceApplication->service); } /** @@ -39,8 +37,7 @@ class ServiceApplicationPolicy */ public function delete(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('delete', $serviceApplication->service); - return true; + return Gate::allows('delete', $serviceApplication->service); } /** @@ -48,8 +45,7 @@ class ServiceApplicationPolicy */ public function restore(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return false; } /** @@ -57,7 +53,6 @@ class ServiceApplicationPolicy */ public function forceDelete(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('delete', $serviceApplication->service); - return true; + return false; } } diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index f72f1f327..88cb15115 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -13,7 +13,7 @@ class ServiceDatabasePolicy */ public function view(User $user, ServiceDatabase $serviceDatabase): bool { - return true; + return Gate::allows('view', $serviceDatabase->service); } /** @@ -21,8 +21,7 @@ class ServiceDatabasePolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -30,9 +29,7 @@ class ServiceDatabasePolicy */ public function update(User $user, ServiceDatabase $serviceDatabase): bool { - - // return Gate::allows('update', $serviceDatabase->service); - return true; + return Gate::allows('update', $serviceDatabase->service); } /** @@ -40,8 +37,7 @@ class ServiceDatabasePolicy */ public function delete(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('delete', $serviceDatabase->service); - return true; + return Gate::allows('delete', $serviceDatabase->service); } /** @@ -49,8 +45,7 @@ class ServiceDatabasePolicy */ public function restore(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('update', $serviceDatabase->service); - return true; + return false; } /** @@ -58,12 +53,22 @@ class ServiceDatabasePolicy */ public function forceDelete(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('delete', $serviceDatabase->service); - return true; + return false; } + /** + * Determine whether the user can manage database backups. + */ public function manageBackups(User $user, ServiceDatabase $serviceDatabase): bool { - return true; + return Gate::allows('update', $serviceDatabase->service); + } + + /** + * Determine whether the user can upload a backup archive for this service database. + */ + public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool + { + return $user->can('uploadBackup', $serviceDatabase->service); } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 7ab0fe7d0..6ca79b42a 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -20,7 +20,9 @@ class ServicePolicy */ public function view(User $user, Service $service): bool { - return true; + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -28,8 +30,7 @@ class ServicePolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,13 +38,9 @@ class ServicePolicy */ public function update(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->isAdmin() && $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -51,12 +48,9 @@ class ServicePolicy */ public function delete(User $user, Service $service): bool { - // if ($user->isAdmin()) { - // return true; - // } + $teamId = $this->getTeamId($service); - // return false; - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -64,8 +58,7 @@ class ServicePolicy */ public function restore(User $user, Service $service): bool { - // return true; - return true; + return false; } /** @@ -73,23 +66,17 @@ class ServicePolicy */ public function forceDelete(User $user, Service $service): bool { - // if ($user->isAdmin()) { - // return true; - // } - - // return false; - return true; + return false; } + /** + * Determine whether the user can stop the service. + */ public function stop(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -97,13 +84,19 @@ class ServicePolicy */ public function manageEnvironment(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->isAdmin() && $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + /** + * Determine whether the user can upload a backup archive for a database within this service. + */ + public function uploadBackup(User $user, Service $service): bool + { + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -111,18 +104,23 @@ class ServicePolicy */ public function deploy(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can access the terminal. + */ public function accessTerminal(User $user, Service $service): bool { - // return $user->isAdmin() || $user->teams->contains('id', $service->team()->id); - return true; + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(Service $service): ?int + { + return $service->team()?->id; } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index b465d8a0c..21b6acb27 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -28,8 +28,7 @@ class SharedEnvironmentVariablePolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,8 +36,7 @@ class SharedEnvironmentVariablePolicy */ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } /** @@ -46,8 +44,7 @@ class SharedEnvironmentVariablePolicy */ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } /** @@ -55,8 +52,7 @@ class SharedEnvironmentVariablePolicy */ public function restore(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return false; } /** @@ -64,8 +60,7 @@ class SharedEnvironmentVariablePolicy */ public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return false; } /** @@ -73,7 +68,6 @@ class SharedEnvironmentVariablePolicy */ public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } } diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php index 3e1f83d12..33eda183a 100644 --- a/app/Policies/StandaloneDockerPolicy.php +++ b/app/Policies/StandaloneDockerPolicy.php @@ -28,8 +28,7 @@ class StandaloneDockerPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,7 +36,7 @@ class StandaloneDockerPolicy */ public function update(User $user, StandaloneDocker $standaloneDocker): bool { - return $user->teams->contains('id', $standaloneDocker->server->team_id); + return $user->isAdminOfTeam($standaloneDocker->server->team_id); } /** @@ -45,7 +44,7 @@ class StandaloneDockerPolicy */ public function delete(User $user, StandaloneDocker $standaloneDocker): bool { - return $user->teams->contains('id', $standaloneDocker->server->team_id); + return $user->isAdminOfTeam($standaloneDocker->server->team_id); } /** diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php index 82a75910b..b19ab4907 100644 --- a/app/Policies/SwarmDockerPolicy.php +++ b/app/Policies/SwarmDockerPolicy.php @@ -28,8 +28,7 @@ class SwarmDockerPolicy */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,7 +36,7 @@ class SwarmDockerPolicy */ public function update(User $user, SwarmDocker $swarmDocker): bool { - return $user->teams->contains('id', $swarmDocker->server->team_id); + return $user->isAdminOfTeam($swarmDocker->server->team_id); } /** @@ -45,7 +44,7 @@ class SwarmDockerPolicy */ public function delete(User $user, SwarmDocker $swarmDocker): bool { - return $user->teams->contains('id', $swarmDocker->server->team_id); + return $user->isAdminOfTeam($swarmDocker->server->team_id); } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 717daf2a2..2d0094f33 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; use Laravel\Telescope\TelescopeServiceProvider; +use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider { @@ -19,6 +20,8 @@ class AppServiceProvider extends ServiceProvider if (App::isLocal()) { $this->app->register(TelescopeServiceProvider::class); } + + $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); } public function boot(): void diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e473d2875..5c1a79cf7 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,9 +3,67 @@ namespace App\Providers; // use Illuminate\Support\Facades\Gate; +use App\Models\Application; +use App\Models\ApplicationPreview; +use App\Models\ApplicationSetting; +use App\Models\CloudInitScript; +use App\Models\CloudProviderToken; +use App\Models\DiscordNotificationSettings; +use App\Models\EmailNotificationSettings; +use App\Models\Environment; +use App\Models\EnvironmentVariable; +use App\Models\GithubApp; +use App\Models\InstanceSettings; +use App\Models\PrivateKey; +use App\Models\Project; +use App\Models\PushoverNotificationSettings; +use App\Models\S3Storage; +use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use App\Models\SharedEnvironmentVariable; +use App\Models\SlackNotificationSettings; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; +use App\Models\Team; +use App\Models\TelegramNotificationSettings; +use App\Models\WebhookNotificationSettings; +use App\Policies\ApiTokenPolicy; +use App\Policies\ApplicationPolicy; +use App\Policies\ApplicationPreviewPolicy; +use App\Policies\ApplicationSettingPolicy; +use App\Policies\CloudInitScriptPolicy; +use App\Policies\CloudProviderTokenPolicy; +use App\Policies\DatabasePolicy; +use App\Policies\EnvironmentPolicy; +use App\Policies\EnvironmentVariablePolicy; +use App\Policies\GithubAppPolicy; +use App\Policies\InstanceSettingsPolicy; +use App\Policies\NotificationPolicy; +use App\Policies\PrivateKeyPolicy; +use App\Policies\ProjectPolicy; use App\Policies\ResourceCreatePolicy; +use App\Policies\S3StoragePolicy; +use App\Policies\ServerPolicy; +use App\Policies\ServiceApplicationPolicy; +use App\Policies\ServiceDatabasePolicy; +use App\Policies\ServicePolicy; +use App\Policies\SharedEnvironmentVariablePolicy; +use App\Policies\StandaloneDockerPolicy; +use App\Policies\SwarmDockerPolicy; +use App\Policies\TeamPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; +use Laravel\Sanctum\PersonalAccessToken; class AuthServiceProvider extends ServiceProvider { @@ -15,49 +73,54 @@ class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - \App\Models\Server::class => \App\Policies\ServerPolicy::class, - \App\Models\PrivateKey::class => \App\Policies\PrivateKeyPolicy::class, - \App\Models\StandaloneDocker::class => \App\Policies\StandaloneDockerPolicy::class, - \App\Models\SwarmDocker::class => \App\Policies\SwarmDockerPolicy::class, - \App\Models\Application::class => \App\Policies\ApplicationPolicy::class, - \App\Models\ApplicationPreview::class => \App\Policies\ApplicationPreviewPolicy::class, - \App\Models\ApplicationSetting::class => \App\Policies\ApplicationSettingPolicy::class, - \App\Models\Service::class => \App\Policies\ServicePolicy::class, - \App\Models\ServiceApplication::class => \App\Policies\ServiceApplicationPolicy::class, - \App\Models\ServiceDatabase::class => \App\Policies\ServiceDatabasePolicy::class, - \App\Models\Project::class => \App\Policies\ProjectPolicy::class, - \App\Models\Environment::class => \App\Policies\EnvironmentPolicy::class, - \App\Models\EnvironmentVariable::class => \App\Policies\EnvironmentVariablePolicy::class, - \App\Models\SharedEnvironmentVariable::class => \App\Policies\SharedEnvironmentVariablePolicy::class, + Server::class => ServerPolicy::class, + PrivateKey::class => PrivateKeyPolicy::class, + StandaloneDocker::class => StandaloneDockerPolicy::class, + SwarmDocker::class => SwarmDockerPolicy::class, + Application::class => ApplicationPolicy::class, + ApplicationPreview::class => ApplicationPreviewPolicy::class, + ApplicationSetting::class => ApplicationSettingPolicy::class, + Service::class => ServicePolicy::class, + ServiceApplication::class => ServiceApplicationPolicy::class, + ServiceDatabase::class => ServiceDatabasePolicy::class, + Project::class => ProjectPolicy::class, + Environment::class => EnvironmentPolicy::class, + EnvironmentVariable::class => EnvironmentVariablePolicy::class, + SharedEnvironmentVariable::class => SharedEnvironmentVariablePolicy::class, // Database policies - all use the shared DatabasePolicy - \App\Models\StandalonePostgresql::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneMysql::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneMariadb::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneMongodb::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneRedis::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneKeydb::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneDragonfly::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneClickhouse::class => \App\Policies\DatabasePolicy::class, + StandalonePostgresql::class => DatabasePolicy::class, + StandaloneMysql::class => DatabasePolicy::class, + StandaloneMariadb::class => DatabasePolicy::class, + StandaloneMongodb::class => DatabasePolicy::class, + StandaloneRedis::class => DatabasePolicy::class, + StandaloneKeydb::class => DatabasePolicy::class, + StandaloneDragonfly::class => DatabasePolicy::class, + StandaloneClickhouse::class => DatabasePolicy::class, // Notification policies - all use the shared NotificationPolicy - \App\Models\EmailNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\DiscordNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\TelegramNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\SlackNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\PushoverNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\WebhookNotificationSettings::class => \App\Policies\NotificationPolicy::class, + EmailNotificationSettings::class => NotificationPolicy::class, + DiscordNotificationSettings::class => NotificationPolicy::class, + TelegramNotificationSettings::class => NotificationPolicy::class, + SlackNotificationSettings::class => NotificationPolicy::class, + PushoverNotificationSettings::class => NotificationPolicy::class, + WebhookNotificationSettings::class => NotificationPolicy::class, // API Token policy - \Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class, + PersonalAccessToken::class => ApiTokenPolicy::class, // Instance settings policy - \App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class, + InstanceSettings::class => InstanceSettingsPolicy::class, + + // S3 storage policy + S3Storage::class => S3StoragePolicy::class, // Team policy - \App\Models\Team::class => \App\Policies\TeamPolicy::class, + Team::class => TeamPolicy::class, // Git source policies - \App\Models\GithubApp::class => \App\Policies\GithubAppPolicy::class, + GithubApp::class => GithubAppPolicy::class, + CloudProviderToken::class => CloudProviderTokenPolicy::class, + CloudInitScript::class => CloudInitScriptPolicy::class, ]; diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index bdb8654b9..2f553a4b9 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -95,9 +95,9 @@ class ValidationPatterns /** * Pattern for Docker-compatible environment variable keys. - * Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL. + * Environment variable keys are later interpolated into shell commands as Docker build args, so only shell-safe identifier characters are allowed. */ - public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u'; + public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; /** * Pattern for SQL-safe unquoted database identifiers (usernames, database names). @@ -164,7 +164,7 @@ class ValidationPatterns public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array { return [ - "{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.", + "{$field}.regex" => "The {$label} must start with a letter or underscore and may only contain letters, numbers, underscores, and dots.", "{$field}.max" => "The {$label} may not be greater than :max characters.", ]; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index bb252148a..a2c3d06da 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -79,6 +79,7 @@ trait ExecuteRemoteCommand $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); + $skip_command_log = data_get($single_command, 'skip_command_log', false); $this->save = data_get($single_command, 'save'); if ($this->server->isNonRoot()) { if (str($command)->startsWith('docker exec')) { @@ -91,7 +92,7 @@ trait ExecuteRemoteCommand // Check for cancellation before executing commands if (isset($this->application_deployment_queue)) { $this->application_deployment_queue->refresh(); - if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { throw new \RuntimeException('Deployment cancelled by user', 69420); } } @@ -103,7 +104,7 @@ trait ExecuteRemoteCommand while ($attempt < $maxRetries && ! $commandExecuted) { try { - $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden); + $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log); $commandExecuted = true; } catch (\RuntimeException|DeploymentException $e) { $lastError = $e; @@ -119,7 +120,7 @@ trait ExecuteRemoteCommand // Check for cancellation during retry wait $this->application_deployment_queue->refresh(); - if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { throw new \RuntimeException('Deployment cancelled by user during retry', 69420); } } @@ -153,14 +154,14 @@ trait ExecuteRemoteCommand /** * Execute the actual command with process handling */ - private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false) + private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false) { - if ($command_hidden && isset($this->application_deployment_queue)) { + if ($command_hidden && ! $skip_command_log && 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, $command_hidden) { + $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, $skip_command_log) { $output = str($output)->trim(); if ($output->startsWith('╔')) { $output = "\n".$output; @@ -170,7 +171,7 @@ trait ExecuteRemoteCommand $sanitized_output = sanitize_utf8_text($output); $new_log_entry = [ - 'command' => $command_hidden ? null : $this->redact_sensitive_info($command), + 'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command), 'output' => $this->redact_sensitive_info($sanitized_output), 'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'), 'timestamp' => Carbon::now('UTC'), @@ -227,7 +228,7 @@ trait ExecuteRemoteCommand // Check if deployment was cancelled while command was running if (isset($this->application_deployment_queue)) { $this->application_deployment_queue->refresh(); - if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { throw new \RuntimeException('Deployment cancelled by user', 69420); } } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php index e46cccf0c..5127e0595 100644 --- a/app/Traits/HasDatabaseStatusInfo.php +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -30,6 +30,8 @@ trait HasDatabaseStatusInfo public ?Carbon $certificateValidUntil = null; + public bool $isPasswordHiddenForMember = false; + abstract protected function databaseLabel(): string; protected function supportsSsl(): bool @@ -73,14 +75,20 @@ trait HasDatabaseStatusInfo public function mount(): void { + $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false; $this->refresh(); } public function refresh(): void { $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; + if ($this->isPasswordHiddenForMember) { + $this->dbUrl = null; + $this->dbUrlPublic = null; + } else { + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + } if ($this->supportsSsl()) { $this->enableSsl = (bool) $this->database->enable_ssl; $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; @@ -167,6 +175,7 @@ trait HasDatabaseStatusInfo 'sslModeHelper' => $this->sslModeHelper(), 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), 'isExited' => str($this->database->status)->contains('exited'), + 'isPasswordHiddenForMember' => $this->isPasswordHiddenForMember, ]); } } diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index b54444261..8511c87db 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -14,6 +14,7 @@ class Button extends Component */ public function __construct( public bool $disabled = false, + public bool $authDisabled = false, public bool $noStyle = false, public ?string $modalId = null, public string $defaultClass = 'button', @@ -28,6 +29,7 @@ class Button extends Component if (! $hasPermission) { $this->disabled = true; + $this->authDisabled = true; } } diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index eb38d84af..e33e4b919 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -6,7 +6,6 @@ use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Checkbox extends Component { @@ -58,7 +57,7 @@ class Checkbox extends Component // Generate unique HTML ID by adding random suffix // This prevents duplicate IDs when multiple forms are on the same page if ($this->id) { - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->id.'-'.$uniqueSuffix; } else { $this->htmlId = $this->id; diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php index 3b7a9ee34..b0f85c8cb 100644 --- a/app/View/Components/Forms/Datalist.php +++ b/app/View/Components/Forms/Datalist.php @@ -6,7 +6,6 @@ use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Datalist extends Component { @@ -55,7 +54,7 @@ class Datalist extends Component $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -64,7 +63,7 @@ class Datalist extends Component // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index faef64a36..2f26e44cc 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -6,7 +6,6 @@ use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class EnvVarInput extends Component { @@ -56,7 +55,7 @@ class EnvVarInput extends Component $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -64,7 +63,7 @@ class EnvVarInput extends Component // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 5ed347f42..303856926 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -5,8 +5,8 @@ namespace App\View\Components\Forms; use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Input extends Component { @@ -51,7 +51,7 @@ class Input extends Component $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -59,7 +59,7 @@ class Input extends Component // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php index 026e3ba8c..327c33da6 100644 --- a/app/View/Components/Forms/Select.php +++ b/app/View/Components/Forms/Select.php @@ -6,7 +6,6 @@ use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Select extends Component { @@ -48,7 +47,7 @@ class Select extends Component $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -57,7 +56,7 @@ class Select extends Component // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php index 02a23a26a..5a5f975c6 100644 --- a/app/View/Components/Forms/Textarea.php +++ b/app/View/Components/Forms/Textarea.php @@ -5,8 +5,8 @@ namespace App\View\Components\Forms; use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Textarea extends Component { @@ -63,7 +63,7 @@ class Textarea extends Component $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -72,7 +72,7 @@ class Textarea extends Component // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/boost.json b/boost.json index 13914521e..e30d446b6 100644 --- a/boost.json +++ b/boost.json @@ -5,23 +5,25 @@ "codex", "opencode" ], + "cloud": false, "guidelines": true, "mcp": true, "nightwatch_mcp": false, "packages": [ - "laravel/fortify", "spatie/laravel-ray", "lorisleiva/laravel-actions" ], "sail": false, "skills": [ + "fortify-development", "laravel-best-practices", "configuring-horizon", + "mcp-development", + "configure-nightwatch", "socialite-development", "livewire-development", "pest-testing", "tailwindcss-development", - "fortify-development", "laravel-actions", "debugging-output-and-previewing-html-using-ray" ] diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 4707b0a07..b7e4af7ab 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -10,7 +10,6 @@ use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) { @@ -192,7 +191,7 @@ function next_after_cancel(?Server $server = null) function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application { - $uuid = $overrides['uuid'] ?? (string) new Cuid2; + $uuid = $overrides['uuid'] ?? new_public_id(); $server = $destination->server; if ($server->team_id !== currentTeam()->id) { @@ -259,7 +258,7 @@ function clone_application(Application $source, $destination, array $overrides = 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'application_id' => $newApplication->id, 'team_id' => currentTeam()->id, ]); @@ -274,7 +273,7 @@ function clone_application(Application $source, $destination, array $overrides = 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'application_id' => $newApplication->id, 'status' => 'exited', 'fqdn' => null, @@ -322,7 +321,7 @@ function clone_application(Application $source, $destination, array $overrides = VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); queue_application_deployment( - deployment_uuid: (string) new Cuid2, + deployment_uuid: new_public_id(), application: $source, server: $sourceServer, destination: $source->destination, diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 4d5e085f3..5f0b2e690 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,12 +17,11 @@ use App\Models\SwarmDocker; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { $database = new StandalonePostgresql; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; $database->postgres_password = Str::password(length: 64, symbols: false); @@ -40,7 +39,7 @@ function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDock function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis { $database = new StandaloneRedis; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'redis-database-'.$database->uuid; $redis_password = Str::password(length: 64, symbols: false); @@ -79,7 +78,7 @@ function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $ function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb { $database = new StandaloneMongodb; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'mongodb-database-'.$database->uuid; $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -96,7 +95,7 @@ function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql { $database = new StandaloneMysql; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'mysql-database-'.$database->uuid; $database->mysql_root_password = Str::password(length: 64, symbols: false); $database->mysql_password = Str::password(length: 64, symbols: false); @@ -114,7 +113,7 @@ function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $ function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb { $database = new StandaloneMariadb; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'mariadb-database-'.$database->uuid; $database->mariadb_root_password = Str::password(length: 64, symbols: false); $database->mariadb_password = Str::password(length: 64, symbols: false); @@ -132,7 +131,7 @@ function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb { $database = new StandaloneKeydb; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'keydb-database-'.$database->uuid; $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -149,7 +148,7 @@ function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $ function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly { $database = new StandaloneDragonfly; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'dragonfly-database-'.$database->uuid; $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -166,7 +165,7 @@ function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDock function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse { $database = new StandaloneClickhouse; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'clickhouse-database-'.$database->uuid; $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2cf159bfd..2f7cc95ef 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -9,7 +9,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection { @@ -459,7 +458,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ foreach ($domains as $loop => $domain) { try { if ($generate_unique_uuid) { - $uuid = new Cuid2; + $uuid = new_public_id(); } $url = Url::fromString($domain); @@ -1364,7 +1363,7 @@ function generateDockerBuildArgs($variables): Collection $key = is_array($var) ? data_get($var, 'key') : $var->key; // Only return the key - Docker will get the value from the environment - return "--build-arg {$key}"; + return '--build-arg '.escapeshellarg((string) $key); }); } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 123cf906a..6632e1fd5 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -14,7 +14,6 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; /** * Validates a Docker Compose YAML string for command injection vulnerabilities. @@ -1240,7 +1239,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $schema = $url->getScheme(); $portInt = $url->getPort(); $port = $portInt !== null ? ':'.$portInt : ''; - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 3a516378f..0f3a7c4dd 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -202,6 +202,11 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); $serverTimezone = getServerTimezone(data_get($application, 'destination.server')); + // Members should never see debug logs, even if an admin enabled debug mode + if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) { + $is_debug_enabled = false; + } + $logs = data_get($application_deployment_queue, 'logs'); if (empty($logs)) { return collect([]); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f2b672fef..7b113e7b8 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -64,7 +64,6 @@ use PurplePixie\PhpDns\DNSQuery; use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; function base_configuration_dir(): string { @@ -115,6 +114,13 @@ function sanitize_string(?string $input = null): ?string return $sanitized; } +function new_public_id(int $length = 24): string +{ + $length = max(1, $length); + + return Str::lower(Str::random($length)); +} + /** * Validate that a path or identifier is safe for use in shell commands. * @@ -455,7 +461,7 @@ function generate_random_name(?string $cuid = null): string ] ); if (is_null($cuid)) { - $cuid = new Cuid2; + $cuid = new_public_id(); } return Str::kebab("{$generator->getName()}-$cuid"); @@ -491,7 +497,7 @@ function formatPrivateKey(string $privateKey) function generate_application_name(string $git_repository, string $git_branch, ?string $cuid = null): string { if (is_null($cuid)) { - $cuid = new Cuid2; + $cuid = new_public_id(); } $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; @@ -1057,7 +1063,6 @@ function sslip(Server $server) function get_service_templates(bool $force = false): Collection { - if ($force) { try { $response = Http::retry(3, 1000)->get(config('constants.services.official')); @@ -1068,15 +1073,16 @@ function get_service_templates(bool $force = false): Collection return collect($services); } catch (Throwable) { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); + return get_service_templates(); } - } else { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); } + + $path = base_path('templates/'.config('constants.services.file_name')); + $mtime = filemtime($path) ?: 0; + + return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) { + return collect(json_decode(File::get($path)))->sortKeys(); + }); } function getResourceByUuid(string $uuid, ?int $teamId = null) @@ -3259,7 +3265,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $template = $resource->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); diff --git a/composer.lock b/composer.lock index 7d958a9cc..ef67627bc 100644 --- a/composer.lock +++ b/composer.lock @@ -1232,25 +1232,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.3", + "version": "7.12.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" + "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", - "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", + "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1259,7 +1260,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.3.2", + "guzzlehttp/test-server": "^0.5.1", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1339,7 +1340,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.3" + "source": "https://github.com/guzzle/guzzle/tree/7.12.1" }, "funding": [ { @@ -1355,24 +1356,25 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:59:19+00:00" + "time": "2026-06-18T14:12:49+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.4.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1422,7 +1424,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.4.1" + "source": "https://github.com/guzzle/promises/tree/2.5.0" }, "funding": [ { @@ -1438,27 +1440,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:57:30+00:00" + "time": "2026-06-02T12:23:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.10.1", + "version": "2.12.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1539,7 +1543,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.1" + "source": "https://github.com/guzzle/psr7/tree/2.12.1" }, "funding": [ { @@ -1555,20 +1559,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:36+00:00" + "time": "2026-06-18T09:49:37+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.5", + "version": "v1.0.7", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + "reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7fe811c23a9e3cd712b4389eaeb50b5456d8c529", + "reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529", "shasum": "" }, "require": { @@ -1577,7 +1581,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1625,7 +1629,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.7" }, "funding": [ { @@ -1641,7 +1645,7 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:27:06+00:00" + "time": "2026-06-12T21:33:43+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1769,16 +1773,16 @@ }, { "name": "laravel/framework", - "version": "v12.60.2", + "version": "v12.61.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39" + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39", - "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", "shasum": "" }, "require": { @@ -1987,7 +1991,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-20T11:48:19+00:00" + "time": "2026-06-04T14:22:52+00:00" }, { "name": "laravel/horizon", @@ -4093,16 +4097,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.4", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", "shasum": "" }, "require": { @@ -4194,7 +4198,7 @@ "type": "tidelift" } ], - "time": "2026-04-07T09:57:54+00:00" + "time": "2026-06-18T13:49:15+00:00" }, { "name": "nette/schema", @@ -5130,16 +5134,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.52", + "version": "3.0.54", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + "reference": "5418963581a6d3e69f030d8c972238cb6add3166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", - "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/5418963581a6d3e69f030d8c972238cb6add3166", + "reference": "5418963581a6d3e69f030d8c972238cb6add3166", "shasum": "" }, "require": { @@ -5220,7 +5224,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.54" }, "funding": [ { @@ -5236,7 +5240,7 @@ "type": "tidelift" } ], - "time": "2026-04-27T07:02:15+00:00" + "time": "2026-06-14T19:54:17+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -6217,20 +6221,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -6289,9 +6293,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "resend/resend-laravel", @@ -8350,16 +8354,16 @@ }, { "name": "symfony/console", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", "shasum": "" }, "require": { @@ -8424,7 +8428,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.11" + "source": "https://github.com/symfony/console/tree/v7.4.13" }, "funding": [ { @@ -8444,7 +8448,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-24T08:56:14+00:00" }, { "name": "symfony/css-selector", @@ -8973,16 +8977,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "bc354f47c62301e990b7874fa662326368508e2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", "shasum": "" }, "require": { @@ -9031,7 +9035,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.8" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" }, "funding": [ { @@ -9051,20 +9055,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" + "reference": "9df847980c436451f4f51d1284491bb4356dd989" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", "shasum": "" }, "require": { @@ -9150,7 +9154,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.12" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" }, "funding": [ { @@ -9170,7 +9174,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:11+00:00" + "time": "2026-05-27T08:31:43+00:00" }, { "name": "symfony/mailer", @@ -9258,16 +9262,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "shasum": "" }, "require": { @@ -9323,7 +9327,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.12" + "source": "https://github.com/symfony/mime/tree/v7.4.13" }, "funding": [ { @@ -9343,7 +9347,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-23T16:22:37+00:00" }, { "name": "symfony/options-resolver", @@ -9585,16 +9589,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -9643,7 +9647,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -9663,7 +9667,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -9839,16 +9843,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -9900,7 +9904,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -9920,7 +9924,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -10008,16 +10012,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -10064,7 +10068,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -10084,20 +10088,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -10144,7 +10148,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -10164,20 +10168,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -10224,7 +10228,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -10244,7 +10248,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-uuid", @@ -10331,16 +10335,16 @@ }, { "name": "symfony/process", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -10372,7 +10376,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.11" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -10392,7 +10396,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:55:21+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/property-access", @@ -10650,16 +10654,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { @@ -10711,7 +10715,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.12" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -10731,7 +10735,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/serializer", @@ -10986,16 +10990,16 @@ }, { "name": "symfony/string", - "version": "v8.0.11", + "version": "v8.0.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc", "shasum": "" }, "require": { @@ -11052,7 +11056,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.11" + "source": "https://github.com/symfony/string/tree/v8.0.13" }, "funding": [ { @@ -11072,7 +11076,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-23T18:05:53+00:00" }, { "name": "symfony/translation", @@ -12014,16 +12018,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -12074,9 +12078,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "yosymfony/parser-utils", diff --git a/config/constants.php b/config/constants.php index a01669673..4a956b31e 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.1.2', + 'version' => '4.2.0', 'helper_version' => '1.0.14', 'realtime_version' => '1.0.16', 'railpack_version' => '0.23.0', diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php new file mode 100644 index 000000000..4da7a2d08 --- /dev/null +++ b/database/factories/CloudProviderTokenFactory.php @@ -0,0 +1,25 @@ + + */ +class CloudProviderTokenFactory extends Factory +{ + protected $model = CloudProviderToken::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'provider' => 'hetzner', + 'token' => 'test-cloud-provider-token', + 'name' => fake()->words(3, true), + ]; + } +} diff --git a/database/factories/PrivateKeyFactory.php b/database/factories/PrivateKeyFactory.php new file mode 100644 index 000000000..51cfdcaa2 --- /dev/null +++ b/database/factories/PrivateKeyFactory.php @@ -0,0 +1,37 @@ + + */ +class PrivateKeyFactory extends Factory +{ + protected $model = PrivateKey::class; + + public function definition(): array + { + return [ + 'name' => fake()->words(2, true), + 'description' => fake()->sentence(), + 'private_key' => $this->privateKey(), + 'team_id' => Team::factory(), + 'is_git_related' => false, + ]; + } + + private function privateKey(): string + { + return '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----'; + } +} diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 123fd226d..19c4445b2 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -11,12 +11,21 @@ return new class extends Migration */ public function up(): void { + // SQLite (testing) uses type affinity, so json columns already accept text. + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text'); } public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json'); } diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php index 78659b457..e736c5ecd 100644 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -7,6 +7,7 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Environment; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -360,6 +361,36 @@ class DevelopmentRailpackExamplesSeeder extends Seeder 'ports_exposes' => '3000', 'git_branch' => 'v4.x', ], + [ + 'uuid' => 'railpack-github-deploy-key', + 'name' => 'Railpack GitHub Deploy Key Example', + 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', + 'git_branch' => 'main', + 'ports_exposes' => '80', + 'private_key_id' => 1, + ], + [ + 'uuid' => 'railpack-gitlab-deploy-key', + 'name' => 'Railpack GitLab Deploy Key Example', + 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', + 'git_branch' => 'main', + 'ports_exposes' => '80', + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'private_key_id' => 1, + ], + [ + 'uuid' => 'railpack-gitlab-public-example', + 'name' => 'Railpack GitLab Public Example', + 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', + 'git_branch' => 'main', + 'base_directory' => '/astro/static', + 'publish_directory' => '/dist', + 'ports_exposes' => '80', + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'is_static' => true, + ], ]; } @@ -420,6 +451,7 @@ KEY, ); $this->ensurePublicGithubSourceExists(); + $this->ensurePublicGitlabSourceExists(); } private function ensurePublicGithubSourceExists(): void @@ -437,6 +469,21 @@ KEY, ); } + private function ensurePublicGitlabSourceExists(): void + { + GitlabApp::query()->firstOrCreate( + ['id' => 1], + [ + 'uuid' => 'gitlab-public', + 'name' => 'Public GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'is_public' => true, + 'team_id' => 0, + ], + ); + } + private function isDevelopmentEnvironment(): bool { return in_array(config('app.env'), ['local', 'development', 'dev'], true); @@ -479,12 +526,12 @@ KEY, 'name' => $example['name'], 'description' => $example['name'], 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", - 'repository_project_id' => self::REPOSITORY_PROJECT_ID, - 'git_repository' => self::GIT_REPOSITORY, + 'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID, + 'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY, 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, 'build_pack' => 'railpack', 'ports_exposes' => $example['ports_exposes'], - 'base_directory' => $example['base_directory'], + 'base_directory' => $example['base_directory'] ?? '/', 'publish_directory' => $example['publish_directory'] ?? null, 'static_image' => 'nginx:alpine', 'install_command' => $example['install_command'] ?? null, @@ -493,8 +540,9 @@ KEY, 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => StandaloneDocker::class, - 'source_id' => 0, - 'source_type' => GithubApp::class, + 'source_id' => $example['source_id'] ?? 0, + 'source_type' => $example['source_type'] ?? GithubApp::class, + 'private_key_id' => $example['private_key_id'] ?? null, ]); $application->save(); diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 9c9a405aa..751db0754 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.1.2" + "version": "4.2.0" }, "nightly": { - "version": "4.2.0" + "version": "4.2.1" }, "helper": { "version": "1.0.14" diff --git a/package-lock.json b/package-lock.json index 9d495c412..fd6843747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,11 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", - "laravel-vite-plugin": "2.0.1", + "laravel-vite-plugin": "3.1.0", "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.2" + "vite": "8.0.16" } }, "node_modules/@alloc/quick-lru": { @@ -34,446 +34,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -526,24 +118,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "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" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "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==", + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -552,12 +159,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -566,12 +176,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "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==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -580,26 +193,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -608,12 +210,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "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==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -622,26 +227,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "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==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -650,12 +244,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "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==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -664,40 +261,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "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==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -706,54 +278,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "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==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -762,12 +295,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "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==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -776,12 +312,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "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==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -790,26 +329,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "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==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -818,12 +346,34 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "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==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -832,26 +382,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "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" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "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==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -860,21 +399,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "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" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1234,12 +769,16 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } }, "node_modules/@types/prismjs": { "version": "1.26.5", @@ -1309,48 +848,6 @@ "node": ">=10.13.0" } }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1402,13 +899,14 @@ } }, "node_modules/laravel-vite-plugin": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", - "integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz", + "integrity": "sha512-Fzocl+X4eQ9jOi0RwdphYRGkUbPJ3ky1pTAST5Ot18cS2gw6d2vldK2eCrlKDVjtibCjCx5qptYDlA0373n7qg==", "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", "vite-plugin-full-reload": "^1.1.0" }, "bin": { @@ -1418,7 +916,13 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^7.0.0" + "fontaine": "^0.5.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } } }, "node_modules/lightningcss": { @@ -1869,49 +1373,38 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@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" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/source-map-js": { @@ -1961,14 +1454,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1977,6 +1470,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1984,18 +1485,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2011,9 +1511,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -2026,15 +1527,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -2081,6 +1585,267 @@ "funding": { "url": "https://github.com/sponsors/jonschlinkert" } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } } } } diff --git a/package.json b/package.json index c3fb1bc5f..3cb3406c7 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", - "laravel-vite-plugin": "2.0.1", + "laravel-vite-plugin": "3.1.0", "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", - "vite": "7.3.2" + "vite": "8.0.16" }, "dependencies": { "@tailwindcss/forms": "0.5.10", diff --git a/public/svgs/clickhouse-icon.svg b/public/svgs/clickhouse-icon.svg new file mode 100644 index 000000000..e327a2a73 --- /dev/null +++ b/public/svgs/clickhouse-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/svgs/clickhouse.svg b/public/svgs/clickhouse.svg index d536536de..e327a2a73 100644 --- a/public/svgs/clickhouse.svg +++ b/public/svgs/clickhouse.svg @@ -1 +1,8 @@ - + + + + + + + + diff --git a/public/svgs/dragonfly.svg b/public/svgs/dragonfly.svg new file mode 100644 index 000000000..d762f3e03 --- /dev/null +++ b/public/svgs/dragonfly.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/inngest.png b/public/svgs/inngest.png new file mode 100644 index 000000000..92d3bcad1 Binary files /dev/null and b/public/svgs/inngest.png differ diff --git a/public/svgs/keydb.svg b/public/svgs/keydb.svg new file mode 100644 index 000000000..0abc24a81 --- /dev/null +++ b/public/svgs/keydb.svg @@ -0,0 +1 @@ + diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 170e6ac16..c982f9f86 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -122,7 +122,11 @@ } @utility button { - @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; + @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-neutral-200 dark:disabled:border-coolgray-300 disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; +} + +@utility auth-tooltip { + @apply fixed z-[99] px-2.5 py-1.5 text-xs rounded-sm pointer-events-none whitespace-nowrap text-neutral-700 bg-neutral-200 dark:text-neutral-300 dark:bg-coolgray-400; } @utility alert-success { @@ -355,4 +359,8 @@ gap: 0; margin-inline: auto; } + + .sidebar-collapsed .sidebar-collapsed-label { + display: none; + } } diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php index e36583741..780a727e1 100644 --- a/resources/views/components/applications/advanced.blade.php +++ b/resources/views/components/applications/advanced.blade.php @@ -3,34 +3,84 @@ Advanced @if ($application->status === 'running') - + @can('deploy', $application) + + + + + + @else + + @endcan @else - + @can('deploy', $application) + + + + + + @else + + @endcan @endif diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php index 4a9de3ca5..24116db25 100644 --- a/resources/views/components/database-status-info.blade.php +++ b/resources/views/components/database-status-info.blade.php @@ -11,6 +11,7 @@ 'certificateValidUntil' => null, 'isExited' => false, 'showPublicUrlPlaceholder' => false, + 'isPasswordHiddenForMember' => false, ]) @php @@ -18,14 +19,19 @@ @endphp
- - @if ($dbUrlPublic) - - @elseif ($showPublicUrlPlaceholder) - + @if ($isPasswordHiddenForMember) + + + @else + + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif @endif @if ($supportsSsl) diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php index 96cdb4420..f1efd8c6c 100644 --- a/resources/views/components/forms/button.blade.php +++ b/resources/views/components/forms/button.blade.php @@ -1,3 +1,24 @@ +@if ($authDisabled) + +@endif +@if ($authDisabled) +
+ You do not have permission to perform this action. +
+
+@endif diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php index eb3f3d8a4..61233b2ca 100644 --- a/resources/views/components/forms/copy-button.blade.php +++ b/resources/views/components/forms/copy-button.blade.php @@ -2,24 +2,27 @@
@if ($label) - + @endif
- - +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index b1d7ac475..4629e3b96 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -6,6 +6,7 @@ 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, + 'authDisabled' => false, 'dispatchAction' => false, 'submitAction' => 'delete', 'content' => null, @@ -151,11 +152,11 @@ @else @if ($disabled) @if ($buttonFullWidth) - + {{ $buttonTitle }} @else - + {{ $buttonTitle }} @endif diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index ecd798cc2..924147e4b 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -44,16 +44,6 @@ this.queryTheme(); this.checkZoom(); }, - setTheme(type) { - this.theme = type; - localStorage.setItem('theme', type); - this.queryTheme(); - }, - cycleTheme() { - const themes = ['light', 'system', 'dark']; - const currentIndex = themes.indexOf(this.theme || localStorage.getItem('theme') || 'dark'); - this.setTheme(themes[(currentIndex + 1) % themes.length]); - }, queryTheme() { const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches; const userSettings = localStorage.getItem('theme') || 'dark'; @@ -375,67 +365,6 @@
  • -
  • - - -
  • @if (isInstanceAdmin() && !isCloud()) @persist('upgrade')
  • diff --git a/resources/views/components/server/sidebar-sentinel.blade.php b/resources/views/components/server/sidebar-sentinel.blade.php index 8125fe22c..421a1ec9d 100644 --- a/resources/views/components/server/sidebar-sentinel.blade.php +++ b/resources/views/components/server/sidebar-sentinel.blade.php @@ -1,10 +1,12 @@ diff --git a/resources/views/components/service-database/sidebar.blade.php b/resources/views/components/service-database/sidebar.blade.php index dc9a30552..9be795305 100644 --- a/resources/views/components/service-database/sidebar.blade.php +++ b/resources/views/components/service-database/sidebar.blade.php @@ -4,7 +4,7 @@ 'isImportSupported' => false, ]) -