diff --git a/.AI_INSTRUCTIONS_SYNC.md b/.AI_INSTRUCTIONS_SYNC.md new file mode 100644 index 000000000..bbe0a90e1 --- /dev/null +++ b/.AI_INSTRUCTIONS_SYNC.md @@ -0,0 +1,156 @@ +# AI Instructions Synchronization Guide + +This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify. + +## Overview + +Coolify maintains AI instructions in two parallel systems: + +1. **CLAUDE.md** - For Claude Code (claude.ai/code) +2. **.cursor/rules/** - For Cursor IDE and other AI assistants + +Both systems share core principles but are optimized for their respective workflows. + +## Structure + +### CLAUDE.md +- **Purpose**: Condensed, workflow-focused guide for Claude Code +- **Format**: Single markdown file +- **Includes**: + - Quick-reference development commands + - High-level architecture overview + - Core patterns and guidelines + - Embedded Laravel Boost guidelines + - References to detailed .cursor/rules/ documentation + +### .cursor/rules/ +- **Purpose**: Detailed, topic-specific documentation +- **Format**: Multiple .mdc files organized by topic +- **Structure**: + - `README.mdc` - Main index and overview + - `cursor_rules.mdc` - Maintenance guidelines + - Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.) +- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants + +## Cross-References + +Both systems reference each other: + +- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation +- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow +- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md + +## Maintaining Consistency + +When updating AI instructions, follow these guidelines: + +### 1. Core Principles (MUST be consistent) +- Laravel version (currently Laravel 12) +- PHP version (8.4) +- Testing execution rules (Docker for Feature tests, mocking for Unit tests) +- Security patterns and authorization requirements +- Code style requirements (Pint, PSR-12) + +### 2. Where to Make Changes + +**For workflow changes** (how to run commands, development setup): +- Primary: `CLAUDE.md` +- Secondary: `.cursor/rules/development-workflow.mdc` + +**For architectural patterns** (how code should be structured): +- Primary: `.cursor/rules/` topic files +- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section + +**For testing patterns**: +- Both: Must be synchronized +- `CLAUDE.md` - Contains condensed testing execution rules +- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns + +### 3. Update Checklist + +When making significant changes: + +- [ ] Identify if change affects core principles (version numbers, critical patterns) +- [ ] Update primary location (CLAUDE.md or .cursor/rules/) +- [ ] Check if update affects cross-referenced content +- [ ] Update secondary location if needed +- [ ] Verify cross-references are still accurate +- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable) + +### 4. Common Inconsistencies to Watch + +- **Version numbers**: Laravel, PHP, package versions +- **Testing instructions**: Docker execution requirements +- **File paths**: Ensure relative paths work from root +- **Command syntax**: Docker commands, artisan commands +- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure + +## File Organization + +``` +/ +├── CLAUDE.md # Claude Code instructions (condensed) +├── .AI_INSTRUCTIONS_SYNC.md # This file +└── .cursor/ + └── rules/ + ├── README.mdc # Index and overview + ├── cursor_rules.mdc # Maintenance guide + ├── testing-patterns.mdc # Testing details + ├── development-workflow.mdc # Dev setup details + ├── security-patterns.mdc # Security details + ├── application-architecture.mdc + ├── deployment-architecture.mdc + ├── database-patterns.mdc + ├── frontend-patterns.mdc + ├── api-and-routing.mdc + ├── form-components.mdc + ├── technology-stack.mdc + ├── project-overview.mdc + └── laravel-boost.mdc # Laravel-specific patterns +``` + +## Recent Updates + +### 2025-10-07 +- ✅ Added cross-references between CLAUDE.md and .cursor/rules/ +- ✅ Synchronized Laravel version (12) across all files +- ✅ Added comprehensive testing execution rules (Docker for Feature tests) +- ✅ Added test design philosophy (prefer mocking over database) +- ✅ Fixed inconsistencies in testing documentation +- ✅ Created this synchronization guide + +## Maintenance Commands + +```bash +# Check for version inconsistencies +grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc + +# Check for PHP version consistency +grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc + +# Format all documentation +./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc + +# Search for specific patterns across all docs +grep -r "pattern_to_check" CLAUDE.md .cursor/rules/ +``` + +## Contributing + +When contributing documentation: + +1. Check both CLAUDE.md and .cursor/rules/ for existing documentation +2. Add to appropriate location(s) based on guidelines above +3. Add cross-references if creating new patterns +4. Update this file if changing organizational structure +5. Verify consistency before submitting PR + +## Questions? + +If unsure about where to document something: + +- **Quick reference / workflow** → CLAUDE.md +- **Detailed patterns / examples** → .cursor/rules/[topic].mdc +- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md + +When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md. diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc new file mode 100644 index 000000000..d0597bb72 --- /dev/null +++ b/.cursor/rules/README.mdc @@ -0,0 +1,297 @@ +--- +description: Complete guide to Coolify Cursor rules and development patterns +globs: .cursor/rules/*.mdc +alwaysApply: false +--- +# Coolify Cursor Rules - Complete Guide + +## Overview + +This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform. + +> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools. +> +> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent. + +## Rule Categories + +### 🏗️ Architecture & Foundation +- **[project-overview.mdc](mdc:.cursor/rules/project-overview.mdc)** - What Coolify is and its core mission +- **[technology-stack.mdc](mdc:.cursor/rules/technology-stack.mdc)** - Complete technology stack and dependencies +- **[application-architecture.mdc](mdc:.cursor/rules/application-architecture.mdc)** - Laravel application structure and patterns + +### 🎨 Frontend Development +- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture +- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization + +### 🗄️ Data & Backend +- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management +- **[deployment-architecture.mdc](mdc:.cursor/rules/deployment-architecture.mdc)** - Docker orchestration and deployment workflows + +### 🌐 API & Communication +- **[api-and-routing.mdc](mdc:.cursor/rules/api-and-routing.mdc)** - RESTful APIs, webhooks, and routing patterns + +### 🧪 Quality Assurance +- **[testing-patterns.mdc](mdc:.cursor/rules/testing-patterns.mdc)** - Testing strategies with Pest PHP and Laravel Dusk + +### 🔧 Development Process +- **[development-workflow.mdc](mdc:.cursor/rules/development-workflow.mdc)** - Development setup, coding standards, and contribution guidelines + +### 🔒 Security +- **[security-patterns.mdc](mdc:.cursor/rules/security-patterns.mdc)** - Security architecture, authentication, and best practices + +## Quick Navigation + +### Core Application Files +- **[app/Models/Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex) +- **[app/Models/Server.php](mdc:app/Models/Server.php)** - Server management (46KB, complex) +- **[app/Models/Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex) +- **[app/Models/Team.php](mdc:app/Models/Team.php)** - Multi-tenant structure (8.9KB) + +### Configuration Files +- **[composer.json](mdc:composer.json)** - PHP dependencies and Laravel setup +- **[package.json](mdc:package.json)** - Frontend dependencies and build scripts +- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration +- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development environment + +### API Documentation +- **[openapi.json](mdc:openapi.json)** - Complete API documentation (373KB) +- **[routes/api.php](mdc:routes/api.php)** - API endpoint definitions (13KB) +- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB) + +## Key Concepts to Understand + +### 1. Multi-Tenant Architecture +Coolify uses a **team-based multi-tenancy** model where: +- Users belong to multiple teams +- Resources are scoped to teams +- Access control is team-based +- Data isolation is enforced at the database level + +### 2. Deployment Philosophy +- **Docker-first** approach for all deployments +- **Zero-downtime** deployments with health checks +- **Git-based** workflows with webhook integration +- **Multi-server** support with SSH connections + +### 3. Technology Stack +- **Backend**: Laravel 12 + PHP 8.4 +- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1 +- **Database**: PostgreSQL 15 + Redis 7 +- **Containerization**: Docker + Docker Compose +- **Testing**: Pest PHP 3.8 + Laravel Dusk + +### 4. Security Model +- **Defense-in-depth** security architecture +- **OAuth integration** with multiple providers +- **API token** authentication with Sanctum +- **Encrypted storage** for sensitive data +- **SSH key** management for server access + +## Development Quick Start + +### Local Setup +```bash +# Clone and setup +git clone https://github.com/coollabsio/coolify.git +cd coolify +cp .env.example .env + +# Docker development (recommended) +docker-compose -f docker-compose.dev.yml up -d +docker-compose exec app composer install +docker-compose exec app npm install +docker-compose exec app php artisan migrate +``` + +### Code Quality +```bash +# PHP code style +./vendor/bin/pint + +# Static analysis +./vendor/bin/phpstan analyse + +# Run tests +./vendor/bin/pest +``` + +## Common Patterns + +### Livewire Components +```php +class ApplicationShow extends Component +{ + public Application $application; + + protected $listeners = [ + 'deployment.started' => 'refresh', + 'deployment.completed' => 'refresh', + ]; + + public function deploy(): void + { + $this->authorize('deploy', $this->application); + app(ApplicationDeploymentService::class)->deploy($this->application); + } +} +``` + +### API Controllers +```php +class ApplicationController extends Controller +{ + public function __construct() + { + $this->middleware('auth:sanctum'); + $this->middleware('team.access'); + } + + public function deploy(Application $application): JsonResponse + { + $this->authorize('deploy', $application); + $deployment = app(ApplicationDeploymentService::class)->deploy($application); + return response()->json(['deployment_id' => $deployment->id]); + } +} +``` + +### Queue Jobs +```php +class DeployApplicationJob implements ShouldQueue +{ + public function handle(DockerService $dockerService): void + { + $this->deployment->update(['status' => 'running']); + + try { + $dockerService->deployContainer($this->deployment->application); + $this->deployment->update(['status' => 'success']); + } catch (Exception $e) { + $this->deployment->update(['status' => 'failed']); + throw $e; + } + } +} +``` + +## Testing Patterns + +### Feature Tests +```php +test('user can deploy application via API', function () { + $user = User::factory()->create(); + $application = Application::factory()->create(['team_id' => $user->currentTeam->id]); + + $response = $this->actingAs($user) + ->postJson("/api/v1/applications/{$application->id}/deploy"); + + $response->assertStatus(200); + expect($application->deployments()->count())->toBe(1); +}); +``` + +### Browser Tests +```php +test('user can create application through UI', function () { + $user = User::factory()->create(); + + $this->browse(function (Browser $browser) use ($user) { + $browser->loginAs($user) + ->visit('/applications/create') + ->type('name', 'Test App') + ->press('Create Application') + ->assertSee('Application created successfully'); + }); +}); +``` + +## Security Considerations + +### Authentication +- Multi-provider OAuth support +- API token authentication +- Team-based access control +- Session management + +### Data Protection +- Encrypted environment variables +- Secure SSH key storage +- Input validation and sanitization +- SQL injection prevention + +### Container Security +- Non-root container users +- Minimal capabilities +- Read-only filesystems +- Network isolation + +## Performance Optimization + +### Database +- Eager loading relationships +- Query optimization +- Connection pooling +- Caching strategies + +### Frontend +- Lazy loading components +- Asset optimization +- CDN integration +- Real-time updates via WebSockets + +## Contributing Guidelines + +### Code Standards +- PSR-12 PHP coding standards +- Laravel best practices +- Comprehensive test coverage +- Security-first approach + +### Pull Request Process +1. Fork repository +2. Create feature branch +3. Implement with tests +4. Run quality checks +5. Submit PR with clear description + +## Useful Commands + +### Development +```bash +# Start development environment +docker-compose -f docker-compose.dev.yml up -d + +# Run tests +./vendor/bin/pest + +# Code formatting +./vendor/bin/pint + +# Frontend development +npm run dev +``` + +### Production +```bash +# Install Coolify +curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + +# Update Coolify +./scripts/upgrade.sh +``` + +## Resources + +### Documentation +- **[README.md](mdc:README.md)** - Project overview and installation +- **[CONTRIBUTING.md](mdc:CONTRIBUTING.md)** - Contribution guidelines +- **[CHANGELOG.md](mdc:CHANGELOG.md)** - Release history +- **[TECH_STACK.md](mdc:TECH_STACK.md)** - Technology overview + +### Configuration +- **[config/](mdc:config)** - Laravel configuration files +- **[database/migrations/](mdc:database/migrations)** - Database schema +- **[tests/](mdc:tests)** - Test suite + +This comprehensive rule set provides everything needed to understand, develop, and contribute to the Coolify project effectively. Each rule focuses on specific aspects while maintaining connections to the broader architecture. diff --git a/.cursor/rules/api-and-routing.mdc b/.cursor/rules/api-and-routing.mdc new file mode 100644 index 000000000..8321205ac --- /dev/null +++ b/.cursor/rules/api-and-routing.mdc @@ -0,0 +1,474 @@ +--- +description: RESTful API design, routing patterns, webhooks, and HTTP communication +globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php +alwaysApply: false +--- +# Coolify API & Routing Architecture + +## Routing Structure + +Coolify implements **multi-layered routing** with web interfaces, RESTful APIs, webhook endpoints, and real-time communication channels. + +## Route Files + +### Core Route Definitions +- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB, 362 lines) +- **[routes/api.php](mdc:routes/api.php)** - RESTful API endpoints (13KB, 185 lines) +- **[routes/webhooks.php](mdc:routes/webhooks.php)** - Webhook receivers (815B, 22 lines) +- **[routes/channels.php](mdc:routes/channels.php)** - WebSocket channel definitions (829B, 33 lines) +- **[routes/console.php](mdc:routes/console.php)** - Artisan command routes (592B, 20 lines) + +## Web Application Routing + +### Authentication Routes +```php +// Laravel Fortify authentication +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthController::class, 'login']); + Route::get('/register', [AuthController::class, 'register']); + Route::get('/forgot-password', [AuthController::class, 'forgotPassword']); +}); +``` + +### Dashboard & Core Features +```php +// Main application routes +Route::middleware(['auth', 'verified'])->group(function () { + Route::get('/dashboard', Dashboard::class)->name('dashboard'); + Route::get('/projects', ProjectIndex::class)->name('projects'); + Route::get('/servers', ServerIndex::class)->name('servers'); + Route::get('/teams', TeamIndex::class)->name('teams'); +}); +``` + +### Resource Management Routes +```php +// Server management +Route::prefix('servers')->group(function () { + Route::get('/{server}', ServerShow::class)->name('server.show'); + Route::get('/{server}/edit', ServerEdit::class)->name('server.edit'); + Route::get('/{server}/logs', ServerLogs::class)->name('server.logs'); +}); + +// Application management +Route::prefix('applications')->group(function () { + Route::get('/{application}', ApplicationShow::class)->name('application.show'); + Route::get('/{application}/deployments', ApplicationDeployments::class); + Route::get('/{application}/environment-variables', ApplicationEnvironmentVariables::class); + Route::get('/{application}/logs', ApplicationLogs::class); +}); +``` + +## RESTful API Architecture + +### API Versioning +```php +// API route structure +Route::prefix('v1')->group(function () { + // Application endpoints + Route::apiResource('applications', ApplicationController::class); + Route::apiResource('servers', ServerController::class); + Route::apiResource('teams', TeamController::class); +}); +``` + +### Authentication & Authorization +```php +// Sanctum API authentication +Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', function (Request $request) { + return $request->user(); + }); + + // Team-scoped resources + Route::middleware('team.access')->group(function () { + Route::apiResource('applications', ApplicationController::class); + }); +}); +``` + +### Application Management API +```php +// Application CRUD operations +Route::prefix('applications')->group(function () { + Route::get('/', [ApplicationController::class, 'index']); + Route::post('/', [ApplicationController::class, 'store']); + Route::get('/{application}', [ApplicationController::class, 'show']); + Route::patch('/{application}', [ApplicationController::class, 'update']); + Route::delete('/{application}', [ApplicationController::class, 'destroy']); + + // Deployment operations + Route::post('/{application}/deploy', [ApplicationController::class, 'deploy']); + Route::post('/{application}/restart', [ApplicationController::class, 'restart']); + Route::post('/{application}/stop', [ApplicationController::class, 'stop']); + Route::get('/{application}/logs', [ApplicationController::class, 'logs']); +}); +``` + +### Server Management API +```php +// Server operations +Route::prefix('servers')->group(function () { + Route::get('/', [ServerController::class, 'index']); + Route::post('/', [ServerController::class, 'store']); + Route::get('/{server}', [ServerController::class, 'show']); + Route::patch('/{server}', [ServerController::class, 'update']); + Route::delete('/{server}', [ServerController::class, 'destroy']); + + // Server actions + Route::post('/{server}/validate', [ServerController::class, 'validate']); + Route::get('/{server}/usage', [ServerController::class, 'usage']); + Route::post('/{server}/cleanup', [ServerController::class, 'cleanup']); +}); +``` + +### Database Management API +```php +// Database operations +Route::prefix('databases')->group(function () { + Route::get('/', [DatabaseController::class, 'index']); + Route::post('/', [DatabaseController::class, 'store']); + Route::get('/{database}', [DatabaseController::class, 'show']); + Route::patch('/{database}', [DatabaseController::class, 'update']); + Route::delete('/{database}', [DatabaseController::class, 'destroy']); + + // Database actions + Route::post('/{database}/backup', [DatabaseController::class, 'backup']); + Route::post('/{database}/restore', [DatabaseController::class, 'restore']); + Route::get('/{database}/logs', [DatabaseController::class, 'logs']); +}); +``` + +## Webhook Architecture + +### Git Integration Webhooks +```php +// GitHub webhook endpoints +Route::post('/webhooks/github/{application}', [GitHubWebhookController::class, 'handle']) + ->name('webhooks.github'); + +// GitLab webhook endpoints +Route::post('/webhooks/gitlab/{application}', [GitLabWebhookController::class, 'handle']) + ->name('webhooks.gitlab'); + +// Generic Git webhooks +Route::post('/webhooks/git/{application}', [GitWebhookController::class, 'handle']) + ->name('webhooks.git'); +``` + +### Deployment Webhooks +```php +// Deployment status webhooks +Route::post('/webhooks/deployment/{deployment}/success', [DeploymentWebhookController::class, 'success']); +Route::post('/webhooks/deployment/{deployment}/failure', [DeploymentWebhookController::class, 'failure']); +Route::post('/webhooks/deployment/{deployment}/progress', [DeploymentWebhookController::class, 'progress']); +``` + +### Third-Party Integration Webhooks +```php +// Monitoring webhooks +Route::post('/webhooks/monitoring/{server}', [MonitoringWebhookController::class, 'handle']); + +// Backup status webhooks +Route::post('/webhooks/backup/{backup}', [BackupWebhookController::class, 'handle']); + +// SSL certificate webhooks +Route::post('/webhooks/ssl/{certificate}', [SslWebhookController::class, 'handle']); +``` + +## WebSocket Channel Definitions + +### Real-Time Channels +```php +// Private channels for team members +Broadcast::channel('team.{teamId}', function ($user, $teamId) { + return $user->teams->contains('id', $teamId); +}); + +// Application deployment channels +Broadcast::channel('application.{applicationId}', function ($user, $applicationId) { + return $user->hasAccessToApplication($applicationId); +}); + +// Server monitoring channels +Broadcast::channel('server.{serverId}', function ($user, $serverId) { + return $user->hasAccessToServer($serverId); +}); +``` + +### Presence Channels +```php +// Team collaboration presence +Broadcast::channel('team.{teamId}.presence', function ($user, $teamId) { + if ($user->teams->contains('id', $teamId)) { + return ['id' => $user->id, 'name' => $user->name]; + } +}); +``` + +## API Controllers + +### Location: [app/Http/Controllers/Api/](mdc:app/Http/Controllers) + +#### Resource Controllers +```php +class ApplicationController extends Controller +{ + public function index(Request $request) + { + return ApplicationResource::collection( + $request->user()->currentTeam->applications() + ->with(['server', 'environment']) + ->paginate() + ); + } + + public function store(StoreApplicationRequest $request) + { + $application = $request->user()->currentTeam + ->applications() + ->create($request->validated()); + + return new ApplicationResource($application); + } + + public function deploy(Application $application) + { + $deployment = $application->deploy(); + + return response()->json([ + 'message' => 'Deployment started', + 'deployment_id' => $deployment->id + ]); + } +} +``` + +### API Responses & Resources +```php +// API Resource classes +class ApplicationResource extends JsonResource +{ + public function toArray($request) + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'fqdn' => $this->fqdn, + 'status' => $this->status, + 'git_repository' => $this->git_repository, + 'git_branch' => $this->git_branch, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'server' => new ServerResource($this->whenLoaded('server')), + 'environment' => new EnvironmentResource($this->whenLoaded('environment')), + ]; + } +} +``` + +## API Authentication + +### Sanctum Token Authentication +```php +// API token generation +Route::post('/auth/tokens', function (Request $request) { + $request->validate([ + 'name' => 'required|string', + 'abilities' => 'array' + ]); + + $token = $request->user()->createToken( + $request->name, + $request->abilities ?? [] + ); + + return response()->json([ + 'token' => $token->plainTextToken, + 'abilities' => $token->accessToken->abilities + ]); +}); +``` + +### Team-Based Authorization +```php +// Team access middleware +class EnsureTeamAccess +{ + public function handle($request, Closure $next) + { + $teamId = $request->route('team'); + + if (!$request->user()->teams->contains('id', $teamId)) { + abort(403, 'Access denied to team resources'); + } + + return $next($request); + } +} +``` + +## Rate Limiting + +### API Rate Limits +```php +// API throttling configuration +RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); +}); + +// Deployment rate limiting +RateLimiter::for('deployments', function (Request $request) { + return Limit::perMinute(10)->by($request->user()->id); +}); +``` + +### Webhook Rate Limiting +```php +// Webhook throttling +RateLimiter::for('webhooks', function (Request $request) { + return Limit::perMinute(100)->by($request->ip()); +}); +``` + +## Route Model Binding + +### Custom Route Bindings +```php +// Custom model binding for applications +Route::bind('application', function ($value) { + return Application::where('uuid', $value) + ->orWhere('id', $value) + ->firstOrFail(); +}); + +// Team-scoped model binding +Route::bind('team_application', function ($value, $route) { + $teamId = $route->parameter('team'); + return Application::whereHas('environment.project', function ($query) use ($teamId) { + $query->where('team_id', $teamId); + })->findOrFail($value); +}); +``` + +## API Documentation + +### OpenAPI Specification +- **[openapi.json](mdc:openapi.json)** - API documentation (373KB, 8316 lines) +- **[openapi.yaml](mdc:openapi.yaml)** - YAML format documentation (184KB, 5579 lines) + +### Documentation Generation +```php +// Swagger/OpenAPI annotations +/** + * @OA\Get( + * path="/api/v1/applications", + * summary="List applications", + * tags={"Applications"}, + * security={{"bearerAuth":{}}}, + * @OA\Response( + * response=200, + * description="List of applications", + * @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/Application")) + * ) + * ) + */ +``` + +## Error Handling + +### API Error Responses +```php +// Standardized error response format +class ApiExceptionHandler +{ + public function render($request, Throwable $exception) + { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $this->getErrorCode($exception), + 'timestamp' => now()->toISOString() + ], $this->getStatusCode($exception)); + } + + return parent::render($request, $exception); + } +} +``` + +### Validation Error Handling +```php +// Form request validation +class StoreApplicationRequest extends FormRequest +{ + public function rules() + { + return [ + 'name' => 'required|string|max:255', + 'git_repository' => 'required|url', + 'git_branch' => 'required|string', + 'server_id' => 'required|exists:servers,id', + 'environment_id' => 'required|exists:environments,id' + ]; + } + + public function failedValidation(Validator $validator) + { + throw new HttpResponseException( + response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors() + ], 422) + ); + } +} +``` + +## Real-Time API Integration + +### WebSocket Events +```php +// Broadcasting deployment events +class DeploymentStarted implements ShouldBroadcast +{ + public $application; + public $deployment; + + public function broadcastOn() + { + return [ + new PrivateChannel("application.{$this->application->id}"), + new PrivateChannel("team.{$this->application->team->id}") + ]; + } + + public function broadcastWith() + { + return [ + 'deployment_id' => $this->deployment->id, + 'status' => 'started', + 'timestamp' => now() + ]; + } +} +``` + +### API Event Streaming +```php +// Server-Sent Events for real-time updates +Route::get('/api/v1/applications/{application}/events', function (Application $application) { + return response()->stream(function () use ($application) { + while (true) { + $events = $application->getRecentEvents(); + foreach ($events as $event) { + echo "data: " . json_encode($event) . "\n\n"; + } + usleep(1000000); // 1 second + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + ]); +}); +``` diff --git a/.cursor/rules/application-architecture.mdc b/.cursor/rules/application-architecture.mdc new file mode 100644 index 000000000..ef8d549ad --- /dev/null +++ b/.cursor/rules/application-architecture.mdc @@ -0,0 +1,368 @@ +--- +description: Laravel application structure, patterns, and architectural decisions +globs: app/**/*.php, config/*.php, bootstrap/**/*.php +alwaysApply: false +--- +# Coolify Application Architecture + +## Laravel Project Structure + +### **Core Application Directory** ([app/](mdc:app)) + +``` +app/ +├── Actions/ # Business logic actions (Action pattern) +├── Console/ # Artisan commands +├── Contracts/ # Interface definitions +├── Data/ # Data Transfer Objects (Spatie Laravel Data) +├── Enums/ # Enumeration classes +├── Events/ # Event classes +├── Exceptions/ # Custom exception classes +├── Helpers/ # Utility helper classes +├── Http/ # HTTP layer (Controllers, Middleware, Requests) +├── Jobs/ # Background job classes +├── Listeners/ # Event listeners +├── Livewire/ # Livewire components (Frontend) +├── Models/ # Eloquent models (Domain entities) +├── Notifications/ # Notification classes +├── Policies/ # Authorization policies +├── Providers/ # Service providers +├── Repositories/ # Repository pattern implementations +├── Services/ # Service layer classes +├── Traits/ # Reusable trait classes +└── View/ # View composers and creators +``` + +## Core Domain Models + +### **Infrastructure Management** + +#### **[Server.php](mdc:app/Models/Server.php)** (46KB, 1343 lines) +- **Purpose**: Physical/virtual server management +- **Key Relationships**: + - `hasMany(Application::class)` - Deployed applications + - `hasMany(StandalonePostgresql::class)` - Database instances + - `belongsTo(Team::class)` - Team ownership +- **Key Features**: + - SSH connection management + - Resource monitoring + - Proxy configuration (Traefik/Caddy) + - Docker daemon interaction + +#### **[Application.php](mdc:app/Models/Application.php)** (74KB, 1734 lines) +- **Purpose**: Application deployment and management +- **Key Relationships**: + - `belongsTo(Server::class)` - Deployment target + - `belongsTo(Environment::class)` - Environment context + - `hasMany(ApplicationDeploymentQueue::class)` - Deployment history +- **Key Features**: + - Git repository integration + - Docker build and deployment + - Environment variable management + - SSL certificate handling + +#### **[Service.php](mdc:app/Models/Service.php)** (58KB, 1325 lines) +- **Purpose**: Multi-container service orchestration +- **Key Relationships**: + - `hasMany(ServiceApplication::class)` - Service components + - `hasMany(ServiceDatabase::class)` - Service databases + - `belongsTo(Environment::class)` - Environment context +- **Key Features**: + - Docker Compose generation + - Service dependency management + - Health check configuration + +### **Team & Project Organization** + +#### **[Team.php](mdc:app/Models/Team.php)** (8.9KB, 308 lines) +- **Purpose**: Multi-tenant team management +- **Key Relationships**: + - `hasMany(User::class)` - Team members + - `hasMany(Project::class)` - Team projects + - `hasMany(Server::class)` - Team servers +- **Key Features**: + - Resource limits and quotas + - Team-based access control + - Subscription management + +#### **[Project.php](mdc:app/Models/Project.php)** (4.3KB, 156 lines) +- **Purpose**: Project organization and grouping +- **Key Relationships**: + - `hasMany(Environment::class)` - Project environments + - `belongsTo(Team::class)` - Team ownership +- **Key Features**: + - Environment isolation + - Resource organization + +#### **[Environment.php](mdc:app/Models/Environment.php)** +- **Purpose**: Environment-specific configuration +- **Key Relationships**: + - `hasMany(Application::class)` - Environment applications + - `hasMany(Service::class)` - Environment services + - `belongsTo(Project::class)` - Project context + +### **Database Management Models** + +#### **Standalone Database Models** +- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** (11KB, 351 lines) +- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** (11KB, 351 lines) +- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** (10KB, 337 lines) +- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** (12KB, 370 lines) +- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** (12KB, 394 lines) +- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** (11KB, 347 lines) +- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** (11KB, 347 lines) +- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** (10KB, 336 lines) + +**Common Features**: +- Database configuration management +- Backup scheduling and execution +- Connection string generation +- Health monitoring + +### **Configuration & Settings** + +#### **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** (7.6KB, 219 lines) +- **Purpose**: Application environment variable management +- **Key Features**: + - Encrypted value storage + - Build-time vs runtime variables + - Shared variable inheritance + +#### **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** (3.2KB, 124 lines) +- **Purpose**: Global Coolify instance configuration +- **Key Features**: + - FQDN and port configuration + - Auto-update settings + - Security configurations + +## Architectural Patterns + +### **Action Pattern** ([app/Actions/](mdc:app/Actions)) + +Using [lorisleiva/laravel-actions](mdc:composer.json) for business logic encapsulation: + +```php +// Example Action structure +class DeployApplication extends Action +{ + public function handle(Application $application): void + { + // Business logic for deployment + } + + public function asJob(Application $application): void + { + // Queue job implementation + } +} +``` + +**Key Action Categories**: +- **Application/**: Deployment and management actions +- **Database/**: Database operations +- **Server/**: Server management actions +- **Service/**: Service orchestration actions + +### **Repository Pattern** ([app/Repositories/](mdc:app/Repositories)) + +Data access abstraction layer: +- Encapsulates database queries +- Provides testable data layer +- Abstracts complex query logic + +### **Service Layer** ([app/Services/](mdc:app/Services)) + +Business logic services: +- External API integrations +- Complex business operations +- Cross-cutting concerns + +## Data Flow Architecture + +### **Request Lifecycle** + +1. **HTTP Request** → [routes/web.php](mdc:routes/web.php) +2. **Middleware** → Authentication, authorization +3. **Livewire Component** → [app/Livewire/](mdc:app/Livewire) +4. **Action/Service** → Business logic execution +5. **Model/Repository** → Data persistence +6. **Response** → Livewire reactive update + +### **Background Processing** + +1. **Job Dispatch** → Queue system (Redis) +2. **Job Processing** → [app/Jobs/](mdc:app/Jobs) +3. **Action Execution** → Business logic +4. **Event Broadcasting** → Real-time updates +5. **Notification** → User feedback + +## Security Architecture + +### **Multi-Tenant Isolation** + +```php +// Team-based query scoping +class Application extends Model +{ + public function scopeOwnedByCurrentTeam($query) + { + return $query->whereHas('environment.project.team', function ($q) { + $q->where('id', currentTeam()->id); + }); + } +} +``` + +### **Authorization Layers** + +1. **Team Membership** → User belongs to team +2. **Resource Ownership** → Resource belongs to team +3. **Policy Authorization** → [app/Policies/](mdc:app/Policies) +4. **Environment Isolation** → Project/environment boundaries + +### **Data Protection** + +- **Environment Variables**: Encrypted at rest +- **SSH Keys**: Secure storage and transmission +- **API Tokens**: Sanctum-based authentication +- **Audit Logging**: [spatie/laravel-activitylog](mdc:composer.json) + +## Configuration Hierarchy + +### **Global Configuration** +- **[InstanceSettings](mdc:app/Models/InstanceSettings.php)**: System-wide settings +- **[config/](mdc:config)**: Laravel configuration files + +### **Team Configuration** +- **[Team](mdc:app/Models/Team.php)**: Team-specific settings +- **[ServerSetting](mdc:app/Models/ServerSetting.php)**: Server configurations + +### **Project Configuration** +- **[ProjectSetting](mdc:app/Models/ProjectSetting.php)**: Project settings +- **[Environment](mdc:app/Models/Environment.php)**: Environment variables + +### **Application Configuration** +- **[ApplicationSetting](mdc:app/Models/ApplicationSetting.php)**: App-specific settings +- **[EnvironmentVariable](mdc:app/Models/EnvironmentVariable.php)**: Runtime configuration + +## Event-Driven Architecture + +### **Event Broadcasting** ([app/Events/](mdc:app/Events)) + +Real-time updates using Laravel Echo and WebSockets: + +```php +// Example event structure +class ApplicationDeploymentStarted implements ShouldBroadcast +{ + public function broadcastOn(): array + { + return [ + new PrivateChannel("team.{$this->application->team->id}"), + ]; + } +} +``` + +### **Event Listeners** ([app/Listeners/](mdc:app/Listeners)) + +- Deployment status updates +- Resource monitoring alerts +- Notification dispatching +- Audit log creation + +## Database Design Patterns + +### **Polymorphic Relationships** + +```php +// Environment variables can belong to multiple resource types +class EnvironmentVariable extends Model +{ + public function resource(): MorphTo + { + return $this->morphTo(); + } +} +``` + +### **Team-Based Soft Scoping** + +All major resources include team-based query scoping: + +```php +// Automatic team filtering +$applications = Application::ownedByCurrentTeam()->get(); +$servers = Server::ownedByCurrentTeam()->get(); +``` + +### **Configuration Inheritance** + +Environment variables cascade from: +1. **Shared Variables** → Team-wide defaults +2. **Project Variables** → Project-specific overrides +3. **Application Variables** → Application-specific values + +## Integration Patterns + +### **Git Provider Integration** + +Abstracted git operations supporting: +- **GitHub**: [app/Models/GithubApp.php](mdc:app/Models/GithubApp.php) +- **GitLab**: [app/Models/GitlabApp.php](mdc:app/Models/GitlabApp.php) +- **Bitbucket**: Webhook integration +- **Gitea**: Self-hosted Git support + +### **Docker Integration** + +- **Container Management**: Direct Docker API communication +- **Image Building**: Dockerfile and Buildpack support +- **Network Management**: Custom Docker networks +- **Volume Management**: Persistent storage handling + +### **SSH Communication** + +- **[phpseclib/phpseclib](mdc:composer.json)**: Secure SSH connections +- **Multiplexing**: Connection pooling for efficiency +- **Key Management**: [PrivateKey](mdc:app/Models/PrivateKey.php) model + +## Testing Architecture + +### **Test Structure** ([tests/](mdc:tests)) + +``` +tests/ +├── Feature/ # Integration tests +├── Unit/ # Unit tests +├── Browser/ # Dusk browser tests +├── Traits/ # Test helper traits +├── Pest.php # Pest configuration +└── TestCase.php # Base test case +``` + +### **Testing Patterns** + +- **Feature Tests**: Full request lifecycle testing +- **Unit Tests**: Individual class/method testing +- **Browser Tests**: End-to-end user workflows +- **Database Testing**: Factories and seeders + +## Performance Considerations + +### **Query Optimization** + +- **Eager Loading**: Prevent N+1 queries +- **Query Scoping**: Team-based filtering +- **Database Indexing**: Optimized for common queries + +### **Caching Strategy** + +- **Redis**: Session and cache storage +- **Model Caching**: Frequently accessed data +- **Query Caching**: Expensive query results + +### **Background Processing** + +- **Queue Workers**: Horizon-managed job processing +- **Job Batching**: Related job grouping +- **Failed Job Handling**: Automatic retry logic diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc new file mode 100644 index 000000000..9edccd496 --- /dev/null +++ b/.cursor/rules/cursor_rules.mdc @@ -0,0 +1,59 @@ +--- +description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness. +globs: .cursor/rules/*.mdc +alwaysApply: true +--- + +# Cursor Rules Maintenance Guide + +> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns. +> +> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/. + +- **Required Rule Structure:** + ```markdown + --- + description: Clear, one-line description of what the rule enforces + globs: path/to/files/*.ext, other/path/**/* + alwaysApply: boolean + --- + + - **Main Points in Bold** + - Sub-points with details + - Examples and explanations + ``` + +- **File References:** + - Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files + - Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references + - Example: [schema.prisma](mdc:prisma/schema.prisma) for code references + +- **Code Examples:** + - Use language-specific code blocks + ```typescript + // ✅ DO: Show good examples + const goodExample = true; + + // ❌ DON'T: Show anti-patterns + const badExample = false; + ``` + +- **Rule Content Guidelines:** + - Start with high-level overview + - Include specific, actionable requirements + - Show examples of correct implementation + - Reference existing code when possible + - Keep rules DRY by referencing other rules + +- **Rule Maintenance:** + - Update rules when new patterns emerge + - Add examples from actual codebase + - Remove outdated patterns + - Cross-reference related rules + +- **Best Practices:** + - Use bullet points for clarity + - Keep descriptions concise + - Include both DO and DON'T examples + - Reference actual code over theoretical examples + - Use consistent formatting across rules \ No newline at end of file diff --git a/.cursor/rules/database-patterns.mdc b/.cursor/rules/database-patterns.mdc new file mode 100644 index 000000000..ec60a43b3 --- /dev/null +++ b/.cursor/rules/database-patterns.mdc @@ -0,0 +1,329 @@ +--- +description: Database architecture, models, migrations, relationships, and data management patterns +globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php +alwaysApply: false +--- +# Coolify Database Architecture & Patterns + +## Database Strategy + +Coolify uses **PostgreSQL 15** as the primary database with **Redis 7** for caching and real-time features. The architecture supports managing multiple external databases across different servers. + +## Primary Database (PostgreSQL) + +### Core Tables & Models + +#### User & Team Management +- **[User.php](mdc:app/Models/User.php)** - User authentication and profiles +- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure +- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Team collaboration invitations +- **[PersonalAccessToken.php](mdc:app/Models/PersonalAccessToken.php)** - API token management + +#### Infrastructure Management +- **[Server.php](mdc:app/Models/Server.php)** - Physical/virtual server definitions (46KB, complex) +- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management +- **[ServerSetting.php](mdc:app/Models/ServerSetting.php)** - Server-specific configurations + +#### Project Organization +- **[Project.php](mdc:app/Models/Project.php)** - Project containers for applications +- **[Environment.php](mdc:app/Models/Environment.php)** - Environment isolation (staging, production, etc.) +- **[ProjectSetting.php](mdc:app/Models/ProjectSetting.php)** - Project-specific settings + +#### Application Deployment +- **[Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex) +- **[ApplicationSetting.php](mdc:app/Models/ApplicationSetting.php)** - Application configurations +- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment orchestration +- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management + +#### Service Management +- **[Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex) +- **[ServiceApplication.php](mdc:app/Models/ServiceApplication.php)** - Service components +- **[ServiceDatabase.php](mdc:app/Models/ServiceDatabase.php)** - Service-attached databases + +## Database Type Support + +### Standalone Database Models +Each database type has its own dedicated model with specific configurations: + +#### SQL Databases +- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** - PostgreSQL instances +- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** - MySQL instances +- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** - MariaDB instances + +#### NoSQL & Analytics +- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** - MongoDB instances +- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** - ClickHouse analytics + +#### Caching & In-Memory +- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** - Redis instances +- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** - KeyDB instances +- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** - Dragonfly instances + +## Configuration Management + +### Environment Variables +- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific environment variables +- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Shared across applications + +### Settings Hierarchy +- **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** - Global Coolify instance settings +- **[ServerSetting.php](mdc:app/Models/ServerSetting.php)** - Server-specific settings +- **[ProjectSetting.php](mdc:app/Models/ProjectSetting.php)** - Project-level settings +- **[ApplicationSetting.php](mdc:app/Models/ApplicationSetting.php)** - Application settings + +## Storage & Backup Systems + +### Storage Management +- **[S3Storage.php](mdc:app/Models/S3Storage.php)** - S3-compatible storage configurations +- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Local filesystem volumes +- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Persistent volume management + +### Backup Infrastructure +- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated backup scheduling +- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking + +### Task Scheduling +- **[ScheduledTask.php](mdc:app/Models/ScheduledTask.php)** - Cron job management +- **[ScheduledTaskExecution.php](mdc:app/Models/ScheduledTaskExecution.php)** - Task execution history + +## Notification & Integration Models + +### Notification Channels +- **[EmailNotificationSettings.php](mdc:app/Models/EmailNotificationSettings.php)** - Email notifications +- **[DiscordNotificationSettings.php](mdc:app/Models/DiscordNotificationSettings.php)** - Discord integration +- **[SlackNotificationSettings.php](mdc:app/Models/SlackNotificationSettings.php)** - Slack integration +- **[TelegramNotificationSettings.php](mdc:app/Models/TelegramNotificationSettings.php)** - Telegram bot +- **[PushoverNotificationSettings.php](mdc:app/Models/PushoverNotificationSettings.php)** - Pushover notifications + +### Source Control Integration +- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub App integration +- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab integration + +### OAuth & Authentication +- **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations + +## Docker & Container Management + +### Container Orchestration +- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Standalone Docker containers +- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm management + +### SSL & Security +- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate management + +## Database Migration Strategy + +### Migration Location: [database/migrations/](mdc:database/migrations) + +#### Migration Patterns +```php +// Typical Coolify migration structure +Schema::create('applications', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('fqdn')->nullable(); + $table->json('environment_variables')->nullable(); + $table->foreignId('destination_id'); + $table->foreignId('source_id'); + $table->timestamps(); +}); +``` + +### Schema Versioning +- **Incremental migrations** for database evolution +- **Data migrations** for complex transformations +- **Rollback support** for deployment safety + +## Eloquent Model Patterns + +### Base Model Structure +- **[BaseModel.php](mdc:app/Models/BaseModel.php)** - Common model functionality +- **UUID primary keys** for distributed systems +- **Soft deletes** for audit trails +- **Activity logging** with Spatie package + +### **CRITICAL: Mass Assignment Protection** +**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`. + +**Checklist for new columns:** +1. ✅ Create migration file +2. ✅ Run migration +3. ✅ **Add column to model's `$fillable` array** +4. ✅ Update any Livewire components that sync this property +5. ✅ Test that the column can be read and written + +**Example:** +```php +class Server extends BaseModel +{ + protected $fillable = [ + 'name', + 'ip', + 'port', + 'is_validating', // ← MUST add new columns here + ]; +} +``` + +### Relationship Patterns +```php +// Typical relationship structure in Application model +class Application extends Model +{ + public function server() + { + return $this->belongsTo(Server::class); + } + + public function environment() + { + return $this->belongsTo(Environment::class); + } + + public function deployments() + { + return $this->hasMany(ApplicationDeploymentQueue::class); + } + + public function environmentVariables() + { + return $this->hasMany(EnvironmentVariable::class); + } +} +``` + +### Model Traits +```php +// Common traits used across models +use SoftDeletes; +use LogsActivity; +use HasFactory; +use HasUuids; +``` + +## Caching Strategy (Redis) + +### Cache Usage Patterns +- **Session storage** - User authentication sessions +- **Queue backend** - Background job processing +- **Model caching** - Expensive query results +- **Real-time data** - WebSocket state management + +### Cache Keys Structure +``` +coolify:session:{session_id} +coolify:server:{server_id}:status +coolify:deployment:{deployment_id}:logs +coolify:user:{user_id}:teams +``` + +## Query Optimization Patterns + +### Eager Loading +```php +// Optimized queries with relationships +$applications = Application::with([ + 'server', + 'environment.project', + 'environmentVariables', + 'deployments' => function ($query) { + $query->latest()->limit(5); + } +])->get(); +``` + +### Chunking for Large Datasets +```php +// Processing large datasets efficiently +Server::chunk(100, function ($servers) { + foreach ($servers as $server) { + // Process server monitoring + } +}); +``` + +### Database Indexes +- **Primary keys** on all tables +- **Foreign key indexes** for relationships +- **Composite indexes** for common queries +- **Unique constraints** for business rules + +## Data Consistency Patterns + +### Database Transactions +```php +// Atomic operations for deployment +DB::transaction(function () { + $application = Application::create($data); + $application->environmentVariables()->createMany($envVars); + $application->deployments()->create(['status' => 'queued']); +}); +``` + +### Model Events +```php +// Automatic cleanup on model deletion +class Application extends Model +{ + protected static function booted() + { + static::deleting(function ($application) { + $application->environmentVariables()->delete(); + $application->deployments()->delete(); + }); + } +} +``` + +## Backup & Recovery + +### Database Backup Strategy +- **Automated PostgreSQL backups** via scheduled tasks +- **Point-in-time recovery** capability +- **Cross-region backup** replication +- **Backup verification** and testing + +### Data Export/Import +- **Application configurations** export/import +- **Environment variable** bulk operations +- **Server configurations** backup and restore + +## Performance Monitoring + +### Query Performance +- **Laravel Telescope** for development debugging +- **Slow query logging** in production +- **Database connection** pooling +- **Read replica** support for scaling + +### Metrics Collection +- **Database size** monitoring +- **Connection count** tracking +- **Query execution time** analysis +- **Cache hit rates** monitoring + +## Multi-Tenancy Pattern + +### Team-Based Isolation +```php +// Global scope for team-based filtering +class Application extends Model +{ + protected static function booted() + { + static::addGlobalScope('team', function (Builder $builder) { + if (auth()->user()) { + $builder->whereHas('environment.project', function ($query) { + $query->where('team_id', auth()->user()->currentTeam->id); + }); + } + }); + } +} +``` + +### Data Separation +- **Team-scoped queries** by default +- **Cross-team access** controls +- **Admin access** patterns +- **Data isolation** guarantees diff --git a/.cursor/rules/deployment-architecture.mdc b/.cursor/rules/deployment-architecture.mdc new file mode 100644 index 000000000..35ae6699b --- /dev/null +++ b/.cursor/rules/deployment-architecture.mdc @@ -0,0 +1,310 @@ +--- +description: Docker orchestration, deployment workflows, and containerization patterns +globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml +alwaysApply: false +--- +# Coolify Deployment Architecture + +## Deployment Philosophy + +Coolify orchestrates **Docker-based deployments** across multiple servers with automated configuration generation, zero-downtime deployments, and comprehensive monitoring. + +## Core Deployment Components + +### Deployment Models +- **[Application.php](mdc:app/Models/Application.php)** - Main application entity with deployment configurations +- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment job orchestration +- **[Service.php](mdc:app/Models/Service.php)** - Multi-container service definitions +- **[Server.php](mdc:app/Models/Server.php)** - Target deployment infrastructure + +### Infrastructure Management +- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management for secure server access +- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Single container deployments +- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm orchestration + +## Deployment Workflow + +### 1. Source Code Integration +``` +Git Repository → Webhook → Coolify → Build & Deploy +``` + +#### Source Control Models +- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub integration and webhooks +- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab CI/CD integration + +#### Deployment Triggers +- **Git push** to configured branches +- **Manual deployment** via UI +- **Scheduled deployments** via cron +- **API-triggered** deployments + +### 2. Build Process +``` +Source Code → Docker Build → Image Registry → Deployment +``` + +#### Build Configurations +- **Dockerfile detection** and custom Dockerfile support +- **Buildpack integration** for framework detection +- **Multi-stage builds** for optimization +- **Cache layer** management for faster builds + +### 3. Deployment Orchestration +``` +Queue Job → Configuration Generation → Container Deployment → Health Checks +``` + +## Deployment Actions + +### Location: [app/Actions/](mdc:app/Actions) + +#### Application Deployment Actions +- **Application/** - Core application deployment logic +- **Docker/** - Docker container management +- **Service/** - Multi-container service orchestration +- **Proxy/** - Reverse proxy configuration + +#### Database Actions +- **Database/** - Database deployment and management +- Automated backup scheduling +- Connection management and health checks + +#### Server Management Actions +- **Server/** - Server provisioning and configuration +- SSH connection establishment +- Docker daemon management + +## Configuration Generation + +### Dynamic Configuration +- **[ConfigurationGenerator.php](mdc:app/Services/ConfigurationGenerator.php)** - Generates deployment configurations +- **[ConfigurationRepository.php](mdc:app/Services/ConfigurationRepository.php)** - Configuration management + +### Generated Configurations +#### Docker Compose Files +```yaml +# Generated docker-compose.yml structure +version: '3.8' +services: + app: + image: ${APP_IMAGE} + environment: + - ${ENV_VARIABLES} + labels: + - traefik.enable=true + - traefik.http.routers.app.rule=Host(`${FQDN}`) + volumes: + - ${VOLUME_MAPPINGS} + networks: + - coolify +``` + +#### Nginx Configurations +- **Reverse proxy** setup +- **SSL termination** with automatic certificates +- **Load balancing** for multiple instances +- **Custom headers** and routing rules + +## Container Orchestration + +### Docker Integration +- **[DockerImageParser.php](mdc:app/Services/DockerImageParser.php)** - Parse and validate Docker images +- **Container lifecycle** management +- **Resource allocation** and limits +- **Network isolation** and communication + +### Volume Management +- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Persistent file storage +- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Data persistence +- **Backup integration** for volume data + +### Network Configuration +- **Custom Docker networks** for isolation +- **Service discovery** between containers +- **Port mapping** and exposure +- **SSL/TLS termination** + +## Environment Management + +### Environment Isolation +- **[Environment.php](mdc:app/Models/Environment.php)** - Development, staging, production environments +- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific variables +- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Cross-application variables + +### Configuration Hierarchy +``` +Instance Settings → Server Settings → Project Settings → Application Settings +``` + +## Preview Environments + +### Git-Based Previews +- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management +- **Automatic PR/MR previews** for feature branches +- **Isolated environments** for testing +- **Automatic cleanup** after merge/close + +### Preview Workflow +``` +Feature Branch → Auto-Deploy → Preview URL → Review → Cleanup +``` + +## SSL & Security + +### Certificate Management +- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation +- **Let's Encrypt** integration for free certificates +- **Custom certificate** upload support +- **Automatic renewal** and monitoring + +### Security Patterns +- **Private Docker networks** for container isolation +- **SSH key-based** server authentication +- **Environment variable** encryption +- **Access control** via team permissions + +## Backup & Recovery + +### Database Backups +- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated database backups +- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking +- **S3-compatible storage** for backup destinations + +### Application Backups +- **Volume snapshots** for persistent data +- **Configuration export** for disaster recovery +- **Cross-region replication** for high availability + +## Monitoring & Logging + +### Real-Time Monitoring +- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Live deployment monitoring +- **WebSocket-based** log streaming +- **Container health checks** and alerts +- **Resource usage** tracking + +### Deployment Logs +- **Build process** logging +- **Container startup** logs +- **Application runtime** logs +- **Error tracking** and alerting + +## Queue System + +### Background Jobs +Location: [app/Jobs/](mdc:app/Jobs) +- **Deployment jobs** for async processing +- **Server monitoring** jobs +- **Backup scheduling** jobs +- **Notification delivery** jobs + +### Queue Processing +- **Redis-backed** job queues +- **Laravel Horizon** for queue monitoring +- **Failed job** retry mechanisms +- **Queue worker** auto-scaling + +## Multi-Server Deployment + +### Server Types +- **Standalone servers** - Single Docker host +- **Docker Swarm** - Multi-node orchestration +- **Remote servers** - SSH-based deployment +- **Local development** - Docker Desktop integration + +### Load Balancing +- **Traefik integration** for automatic load balancing +- **Health check** based routing +- **Blue-green deployments** for zero downtime +- **Rolling updates** with configurable strategies + +## Deployment Strategies + +### Zero-Downtime Deployment +``` +Old Container → New Container Build → Health Check → Traffic Switch → Old Container Cleanup +``` + +### Blue-Green Deployment +- **Parallel environments** for safe deployments +- **Instant rollback** capability +- **Database migration** handling +- **Configuration synchronization** + +### Rolling Updates +- **Gradual instance** replacement +- **Configurable update** strategy +- **Automatic rollback** on failure +- **Health check** validation + +## API Integration + +### Deployment API +Routes: [routes/api.php](mdc:routes/api.php) +- **RESTful endpoints** for deployment management +- **Webhook receivers** for CI/CD integration +- **Status reporting** endpoints +- **Deployment triggering** via API + +### Authentication +- **Laravel Sanctum** API tokens +- **Team-based** access control +- **Rate limiting** for API calls +- **Audit logging** for API usage + +## Error Handling & Recovery + +### Deployment Failure Recovery +- **Automatic rollback** on deployment failure +- **Health check** failure handling +- **Container crash** recovery +- **Resource exhaustion** protection + +### Monitoring & Alerting +- **Failed deployment** notifications +- **Resource threshold** alerts +- **SSL certificate** expiry warnings +- **Backup failure** notifications + +## Performance Optimization + +### Build Optimization +- **Docker layer** caching +- **Multi-stage builds** for smaller images +- **Build artifact** reuse +- **Parallel build** processing + +### Runtime Optimization +- **Container resource** limits +- **Auto-scaling** based on metrics +- **Connection pooling** for databases +- **CDN integration** for static assets + +## Compliance & Governance + +### Audit Trail +- **Deployment history** tracking +- **Configuration changes** logging +- **User action** auditing +- **Resource access** monitoring + +### Backup Compliance +- **Retention policies** for backups +- **Encryption at rest** for sensitive data +- **Cross-region** backup replication +- **Recovery testing** automation + +## Integration Patterns + +### CI/CD Integration +- **GitHub Actions** compatibility +- **GitLab CI** pipeline integration +- **Custom webhook** endpoints +- **Build status** reporting + +### External Services +- **S3-compatible** storage integration +- **External database** connections +- **Third-party monitoring** tools +- **Custom notification** channels diff --git a/.cursor/rules/dev_workflow.mdc b/.cursor/rules/dev_workflow.mdc new file mode 100644 index 000000000..003251d8a --- /dev/null +++ b/.cursor/rules/dev_workflow.mdc @@ -0,0 +1,219 @@ +--- +description: Guide for using Task Master to manage task-driven development workflows +globs: **/* +alwaysApply: true +--- +# Task Master Development Workflow + +This guide outlines the typical process for using Task Master to manage software development projects. + +## Primary Interaction: MCP Server vs. CLI + +Task Master offers two primary ways to interact: + +1. **MCP Server (Recommended for Integrated Tools)**: + - For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**. + - The MCP server exposes Task Master functionality through a set of tools (e.g., `get_tasks`, `add_subtask`). + - This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing. + - Refer to [`mcp.mdc`](mdc:.cursor/rules/mcp.mdc) for details on the MCP architecture and available tools. + - A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc). + - **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change. + +2. **`task-master` CLI (For Users & Fallback)**: + - The global `task-master` command provides a user-friendly interface for direct terminal interaction. + - It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP. + - Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`. + - The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`). + - Refer to [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc) for a detailed command reference. + +## Standard Development Workflow Process + +- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input=''` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to generate initial tasks.json +- Begin coding sessions with `get_tasks` / `task-master list` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to see current tasks, status, and IDs +- Determine the next task to work on using `next_task` / `task-master next` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before breaking down tasks +- Review complexity report using `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Select tasks based on dependencies (all marked 'done'), priority level, and ID order +- Clarify tasks by checking task files in tasks/ directory or asking for user input +- View specific task details using `get_task` / `task-master show ` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to understand implementation requirements +- Break down complex tasks using `expand_task` / `task-master expand --id= --force --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) with appropriate flags like `--force` (to replace existing subtasks) and `--research`. +- Clear existing subtasks if needed using `clear_subtasks` / `task-master clear-subtasks --id=` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before regenerating +- Implement code following task details, dependencies, and project standards +- Verify tasks according to test strategies before marking as complete (See [`tests.mdc`](mdc:.cursor/rules/tests.mdc)) +- Mark completed tasks with `set_task_status` / `task-master set-status --id= --status=done` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) +- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from= --prompt="..."` or `update_task` / `task-master update-task --id= --prompt="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) +- Add new tasks discovered during implementation using `add_task` / `task-master add-task --prompt="..." --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Add new subtasks as needed using `add_subtask` / `task-master add-subtask --parent= --title="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Append notes or details to subtasks using `update_subtask` / `task-master update-subtask --id= --prompt='Add implementation notes here...\nMore details...'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Generate task files with `generate` / `task-master generate` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) after updating tasks.json +- Maintain valid dependency structure with `add_dependency`/`remove_dependency` tools or `task-master add-dependency`/`remove-dependency` commands, `validate_dependencies` / `task-master validate-dependencies`, and `fix_dependencies` / `task-master fix-dependencies` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) when needed +- Respect dependency chains and task priorities when selecting work +- Report progress regularly using `get_tasks` / `task-master list` + +## Task Complexity Analysis + +- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for comprehensive analysis +- Review complexity report via `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for a formatted, readable version. +- Focus on tasks with highest complexity scores (8-10) for detailed breakdown +- Use analysis results to determine appropriate subtask allocation +- Note that reports are automatically used by the `expand_task` tool/command + +## Task Breakdown Process + +- Use `expand_task` / `task-master expand --id=`. It automatically uses the complexity report if found, otherwise generates default number of subtasks. +- Use `--num=` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations. +- Add `--research` flag to leverage Perplexity AI for research-backed expansion. +- Add `--force` flag to clear existing subtasks before generating new ones (default is to append). +- Use `--prompt=""` to provide additional context when needed. +- Review and adjust generated subtasks as necessary. +- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`. +- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=`. + +## Implementation Drift Handling + +- When implementation differs significantly from planned approach +- When future tasks need modification due to current implementation choices +- When new dependencies or requirements emerge +- Use `update` / `task-master update --from= --prompt='\nUpdate context...' --research` to update multiple future tasks. +- Use `update_task` / `task-master update-task --id= --prompt='\nUpdate context...' --research` to update a single specific task. + +## Task Status Management + +- Use 'pending' for tasks ready to be worked on +- Use 'done' for completed and verified tasks +- Use 'deferred' for postponed tasks +- Add custom status values as needed for project-specific workflows + +## Task Structure Fields + +- **id**: Unique identifier for the task (Example: `1`, `1.1`) +- **title**: Brief, descriptive title (Example: `"Initialize Repo"`) +- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`) +- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`) +- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`) + - Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending) + - This helps quickly identify which prerequisite tasks are blocking work +- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`) +- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`) +- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`) +- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`) +- Refer to task structure details (previously linked to `tasks.mdc`). + +## Configuration Management (Updated) + +Taskmaster configuration is managed through two main mechanisms: + +1. **`.taskmasterconfig` File (Primary):** + * Located in the project root directory. + * Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc. + * **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing. + * **View/Set specific models via `task-master models` command or `models` MCP tool.** + * Created automatically when you run `task-master models --setup` for the first time. + +2. **Environment Variables (`.env` / `mcp.json`):** + * Used **only** for sensitive API keys and specific endpoint URLs. + * Place API keys (one per provider) in a `.env` file in the project root for CLI usage. + * For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`. + * Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`). + +**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool. +**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`. +**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project. + +## Determining the Next Task + +- Run `next_task` / `task-master next` to show the next task to work on. +- The command identifies tasks with all dependencies satisfied +- Tasks are prioritized by priority level, dependency count, and ID +- The command shows comprehensive task information including: + - Basic task details and description + - Implementation details + - Subtasks (if they exist) + - Contextual suggested actions +- Recommended before starting any new development work +- Respects your project's dependency structure +- Ensures tasks are completed in the appropriate sequence +- Provides ready-to-use commands for common task actions + +## Viewing Specific Task Details + +- Run `get_task` / `task-master show ` to view a specific task. +- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1) +- Displays comprehensive information similar to the next command, but for a specific task +- For parent tasks, shows all subtasks and their current status +- For subtasks, shows parent task information and relationship +- Provides contextual suggested actions appropriate for the specific task +- Useful for examining task details before implementation or checking status + +## Managing Task Dependencies + +- Use `add_dependency` / `task-master add-dependency --id= --depends-on=` to add a dependency. +- Use `remove_dependency` / `task-master remove-dependency --id= --depends-on=` to remove a dependency. +- The system prevents circular dependencies and duplicate dependency entries +- Dependencies are checked for existence before being added or removed +- Task files are automatically regenerated after dependency changes +- Dependencies are visualized with status indicators in task listings and files + +## Iterative Subtask Implementation + +Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation: + +1. **Understand the Goal (Preparation):** + * Use `get_task` / `task-master show ` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to thoroughly understand the specific goals and requirements of the subtask. + +2. **Initial Exploration & Planning (Iteration 1):** + * This is the first attempt at creating a concrete implementation plan. + * Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification. + * Determine the intended code changes (diffs) and their locations. + * Gather *all* relevant details from this exploration phase. + +3. **Log the Plan:** + * Run `update_subtask` / `task-master update-subtask --id= --prompt=''`. + * Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`. + +4. **Verify the Plan:** + * Run `get_task` / `task-master show ` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details. + +5. **Begin Implementation:** + * Set the subtask status using `set_task_status` / `task-master set-status --id= --status=in-progress`. + * Start coding based on the logged plan. + +6. **Refine and Log Progress (Iteration 2+):** + * As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches. + * **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy. + * **Regularly** use `update_subtask` / `task-master update-subtask --id= --prompt='\n- What worked...\n- What didn't work...'` to append new findings. + * **Crucially, log:** + * What worked ("fundamental truths" discovered). + * What didn't work and why (to avoid repeating mistakes). + * Specific code snippets or configurations that were successful. + * Decisions made, especially if confirmed with user input. + * Any deviations from the initial plan and the reasoning. + * The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors. + +7. **Review & Update Rules (Post-Implementation):** + * Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history. + * Identify any new or modified code patterns, conventions, or best practices established during the implementation. + * Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`). + +8. **Mark Task Complete:** + * After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id= --status=done`. + +9. **Commit Changes (If using Git):** + * Stage the relevant code changes and any updated/new rule files (`git add .`). + * Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments. + * Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask \n\n- Details about changes...\n- Updated rule Y for pattern Z'`). + * Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one. + +10. **Proceed to Next Subtask:** + * Identify the next subtask (e.g., using `next_task` / `task-master next`). + +## Code Analysis & Refactoring Techniques + +- **Top-Level Function Search**: + - Useful for understanding module structure or planning refactors. + - Use grep/ripgrep to find exported functions/constants: + `rg "export (async function|function|const) \w+"` or similar patterns. + - Can help compare functions between files during migrations or identify potential naming conflicts. + +--- +*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.* \ No newline at end of file diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc new file mode 100644 index 000000000..175b7d85a --- /dev/null +++ b/.cursor/rules/development-workflow.mdc @@ -0,0 +1,653 @@ +--- +description: Development setup, coding standards, contribution guidelines, and best practices +globs: **/*.php, composer.json, package.json, *.md, .env.example +alwaysApply: false +--- +# Coolify Development Workflow + +## Development Environment Setup + +### Prerequisites +- **PHP 8.4+** - Latest PHP version for modern features +- **Node.js 18+** - For frontend asset compilation +- **Docker & Docker Compose** - Container orchestration +- **PostgreSQL 15** - Primary database +- **Redis 7** - Caching and queues + +### Local Development Setup + +#### Using Docker (Recommended) +```bash +# Clone the repository +git clone https://github.com/coollabsio/coolify.git +cd coolify + +# Copy environment configuration +cp .env.example .env + +# Start development environment +docker-compose -f docker-compose.dev.yml up -d + +# Install PHP dependencies +docker-compose exec app composer install + +# Install Node.js dependencies +docker-compose exec app npm install + +# Generate application key +docker-compose exec app php artisan key:generate + +# Run database migrations +docker-compose exec app php artisan migrate + +# Seed development data +docker-compose exec app php artisan db:seed +``` + +#### Native Development +```bash +# Install PHP dependencies +composer install + +# Install Node.js dependencies +npm install + +# Setup environment +cp .env.example .env +php artisan key:generate + +# Setup database +createdb coolify_dev +php artisan migrate +php artisan db:seed + +# Start development servers +php artisan serve & +npm run dev & +php artisan queue:work & +``` + +## Development Tools & Configuration + +### Code Quality Tools +- **[Laravel Pint](mdc:pint.json)** - PHP code style fixer +- **[Rector](mdc:rector.php)** - PHP automated refactoring (989B, 35 lines) +- **PHPStan** - Static analysis for type safety +- **ESLint** - JavaScript code quality + +### Development Configuration Files +- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development Docker setup (3.4KB, 126 lines) +- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration (1.0KB, 42 lines) +- **[.editorconfig](mdc:.editorconfig)** - Code formatting standards (258B, 19 lines) + +### Git Configuration +- **[.gitignore](mdc:.gitignore)** - Version control exclusions (522B, 40 lines) +- **[.gitattributes](mdc:.gitattributes)** - Git file handling (185B, 11 lines) + +## Development Workflow Process + +### 1. Feature Development +```bash +# Create feature branch +git checkout -b feature/new-deployment-strategy + +# Make changes following coding standards +# Run code quality checks +./vendor/bin/pint +./vendor/bin/rector process --dry-run +./vendor/bin/phpstan analyse + +# Run tests +./vendor/bin/pest +./vendor/bin/pest --coverage + +# Commit changes +git add . +git commit -m "feat: implement blue-green deployment strategy" +``` + +### 2. Code Review Process +```bash +# Push feature branch +git push origin feature/new-deployment-strategy + +# Create pull request with: +# - Clear description of changes +# - Screenshots for UI changes +# - Test coverage information +# - Breaking change documentation +``` + +### 3. Testing Requirements +- **Unit tests** for new models and services +- **Feature tests** for API endpoints +- **Browser tests** for UI changes +- **Integration tests** for deployment workflows + +## Coding Standards & Conventions + +### PHP Coding Standards +```php +// Follow PSR-12 coding standards +class ApplicationDeploymentService +{ + public function __construct( + private readonly DockerService $dockerService, + private readonly ConfigurationGenerator $configGenerator + ) {} + + public function deploy(Application $application): ApplicationDeploymentQueue + { + return DB::transaction(function () use ($application) { + $deployment = $application->deployments()->create([ + 'status' => 'queued', + 'commit_sha' => $application->getLatestCommitSha(), + ]); + + DeployApplicationJob::dispatch($deployment); + + return $deployment; + }); + } +} +``` + +### Laravel Best Practices +```php +// Use Laravel conventions +class Application extends Model +{ + // Mass assignment protection + protected $fillable = [ + 'name', 'git_repository', 'git_branch', 'fqdn' + ]; + + // Type casting + protected $casts = [ + 'environment_variables' => 'array', + 'build_pack' => BuildPack::class, + 'created_at' => 'datetime', + ]; + + // Relationships + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function deployments(): HasMany + { + return $this->hasMany(ApplicationDeploymentQueue::class); + } +} +``` + +### Frontend Standards +```javascript +// Alpine.js component structure +document.addEventListener('alpine:init', () => { + Alpine.data('deploymentMonitor', () => ({ + status: 'idle', + logs: [], + + init() { + this.connectWebSocket(); + }, + + connectWebSocket() { + Echo.private(`application.${this.applicationId}`) + .listen('DeploymentStarted', (e) => { + this.status = 'deploying'; + }) + .listen('DeploymentCompleted', (e) => { + this.status = 'completed'; + }); + } + })); +}); +``` + +### CSS/Tailwind Standards +```html + +
+
+

+ Application Status +

+
+ +
+
+
+``` + +## Database Development + +### Migration Best Practices +```php +// Create descriptive migration files +class CreateApplicationDeploymentQueuesTable extends Migration +{ + public function up(): void + { + Schema::create('application_deployment_queues', function (Blueprint $table) { + $table->id(); + $table->foreignId('application_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('queued'); + $table->string('commit_sha')->nullable(); + $table->text('build_logs')->nullable(); + $table->text('deployment_logs')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + + $table->index(['application_id', 'status']); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('application_deployment_queues'); + } +} +``` + +### Model Factory Development +```php +// Create comprehensive factories for testing +class ApplicationFactory extends Factory +{ + protected $model = Application::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->words(2, true), + 'fqdn' => $this->faker->domainName, + 'git_repository' => 'https://github.com/' . $this->faker->userName . '/' . $this->faker->word . '.git', + 'git_branch' => 'main', + 'build_pack' => BuildPack::NIXPACKS, + 'server_id' => Server::factory(), + 'environment_id' => Environment::factory(), + ]; + } + + public function withCustomDomain(): static + { + return $this->state(fn (array $attributes) => [ + 'fqdn' => $this->faker->domainName, + ]); + } +} +``` + +## API Development + +### Controller Standards +```php +class ApplicationController extends Controller +{ + public function __construct() + { + $this->middleware('auth:sanctum'); + $this->middleware('team.access'); + } + + public function index(Request $request): AnonymousResourceCollection + { + $applications = $request->user() + ->currentTeam + ->applications() + ->with(['server', 'environment', 'latestDeployment']) + ->paginate(); + + return ApplicationResource::collection($applications); + } + + public function store(StoreApplicationRequest $request): ApplicationResource + { + $application = $request->user() + ->currentTeam + ->applications() + ->create($request->validated()); + + return new ApplicationResource($application); + } + + public function deploy(Application $application): JsonResponse + { + $this->authorize('deploy', $application); + + $deployment = app(ApplicationDeploymentService::class) + ->deploy($application); + + return response()->json([ + 'message' => 'Deployment started successfully', + 'deployment_id' => $deployment->id, + ]); + } +} +``` + +### API Resource Development +```php +class ApplicationResource extends JsonResource +{ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'fqdn' => $this->fqdn, + 'status' => $this->status, + 'git_repository' => $this->git_repository, + 'git_branch' => $this->git_branch, + 'build_pack' => $this->build_pack, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + + // Conditional relationships + 'server' => new ServerResource($this->whenLoaded('server')), + 'environment' => new EnvironmentResource($this->whenLoaded('environment')), + 'latest_deployment' => new DeploymentResource($this->whenLoaded('latestDeployment')), + + // Computed attributes + 'deployment_url' => $this->getDeploymentUrl(), + 'can_deploy' => $this->canDeploy(), + ]; + } +} +``` + +## Livewire Component Development + +### Component Structure +```php +class ApplicationShow extends Component +{ + public Application $application; + public bool $showLogs = false; + + protected $listeners = [ + 'deployment.started' => 'refreshDeploymentStatus', + 'deployment.completed' => 'refreshDeploymentStatus', + ]; + + public function mount(Application $application): void + { + $this->authorize('view', $application); + $this->application = $application; + } + + public function deploy(): void + { + $this->authorize('deploy', $this->application); + + try { + app(ApplicationDeploymentService::class)->deploy($this->application); + + $this->dispatch('deployment.started', [ + 'application_id' => $this->application->id + ]); + + session()->flash('success', 'Deployment started successfully'); + } catch (Exception $e) { + session()->flash('error', 'Failed to start deployment: ' . $e->getMessage()); + } + } + + public function refreshDeploymentStatus(): void + { + $this->application->refresh(); + } + + public function render(): View + { + return view('livewire.application.show', [ + 'deployments' => $this->application + ->deployments() + ->latest() + ->limit(10) + ->get() + ]); + } +} +``` + +## Queue Job Development + +### Job Structure +```php +class DeployApplicationJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public int $tries = 3; + public int $maxExceptions = 1; + + public function __construct( + public ApplicationDeploymentQueue $deployment + ) {} + + public function handle( + DockerService $dockerService, + ConfigurationGenerator $configGenerator + ): void { + $this->deployment->update(['status' => 'running', 'started_at' => now()]); + + try { + // Generate configuration + $config = $configGenerator->generateDockerCompose($this->deployment->application); + + // Build and deploy + $imageTag = $dockerService->buildImage($this->deployment->application); + $dockerService->deployContainer($this->deployment->application, $imageTag); + + $this->deployment->update([ + 'status' => 'success', + 'finished_at' => now() + ]); + + // Broadcast success + broadcast(new DeploymentCompleted($this->deployment)); + + } catch (Exception $e) { + $this->deployment->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + 'finished_at' => now() + ]); + + broadcast(new DeploymentFailed($this->deployment)); + + throw $e; + } + } + + public function backoff(): array + { + return [1, 5, 10]; + } + + public function failed(Throwable $exception): void + { + $this->deployment->update([ + 'status' => 'failed', + 'error_message' => $exception->getMessage(), + 'finished_at' => now() + ]); + } +} +``` + +## Testing Development + +### Test Structure +```php +// Feature test example +test('user can deploy application via API', function () { + $user = User::factory()->create(); + $application = Application::factory()->create([ + 'team_id' => $user->currentTeam->id + ]); + + // Mock external services + $this->mock(DockerService::class, function ($mock) { + $mock->shouldReceive('buildImage')->andReturn('app:latest'); + $mock->shouldReceive('deployContainer')->andReturn(true); + }); + + $response = $this->actingAs($user) + ->postJson("/api/v1/applications/{$application->id}/deploy"); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'Deployment started successfully' + ]); + + expect($application->deployments()->count())->toBe(1); + expect($application->deployments()->first()->status)->toBe('queued'); +}); +``` + +## Documentation Standards + +### Code Documentation +```php +/** + * Deploy an application to the specified server. + * + * This method creates a new deployment queue entry and dispatches + * a background job to handle the actual deployment process. + * + * @param Application $application The application to deploy + * @param array $options Additional deployment options + * @return ApplicationDeploymentQueue The created deployment queue entry + * + * @throws DeploymentException When deployment cannot be started + * @throws ServerConnectionException When server is unreachable + */ +public function deploy(Application $application, array $options = []): ApplicationDeploymentQueue +{ + // Implementation +} +``` + +### API Documentation +```php +/** + * @OA\Post( + * path="/api/v1/applications/{application}/deploy", + * summary="Deploy an application", + * description="Triggers a new deployment for the specified application", + * operationId="deployApplication", + * tags={"Applications"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="application", + * in="path", + * required=true, + * @OA\Schema(type="integer"), + * description="Application ID" + * ), + * @OA\Response( + * response=200, + * description="Deployment started successfully", + * @OA\JsonContent( + * @OA\Property(property="message", type="string"), + * @OA\Property(property="deployment_id", type="integer") + * ) + * ) + * ) + */ +``` + +## Performance Optimization + +### Database Optimization +```php +// Use eager loading to prevent N+1 queries +$applications = Application::with([ + 'server:id,name,ip', + 'environment:id,name', + 'latestDeployment:id,application_id,status,created_at' +])->get(); + +// Use database transactions for consistency +DB::transaction(function () use ($application) { + $deployment = $application->deployments()->create(['status' => 'queued']); + $application->update(['last_deployment_at' => now()]); + DeployApplicationJob::dispatch($deployment); +}); +``` + +### Caching Strategies +```php +// Cache expensive operations +public function getServerMetrics(Server $server): array +{ + return Cache::remember( + "server.{$server->id}.metrics", + now()->addMinutes(5), + fn () => $this->fetchServerMetrics($server) + ); +} +``` + +## Deployment & Release Process + +### Version Management +- **[versions.json](mdc:versions.json)** - Version tracking (355B, 19 lines) +- **[CHANGELOG.md](mdc:CHANGELOG.md)** - Release notes (187KB, 7411 lines) +- **[cliff.toml](mdc:cliff.toml)** - Changelog generation (3.2KB, 85 lines) + +### Release Workflow +```bash +# Create release branch +git checkout -b release/v4.1.0 + +# Update version numbers +# Update CHANGELOG.md +# Run full test suite +./vendor/bin/pest +npm run test + +# Create release commit +git commit -m "chore: release v4.1.0" + +# Create and push tag +git tag v4.1.0 +git push origin v4.1.0 + +# Merge to main +git checkout main +git merge release/v4.1.0 +``` + +## Contributing Guidelines + +### Pull Request Process +1. **Fork** the repository +2. **Create** feature branch from `main` +3. **Implement** changes with tests +4. **Run** code quality checks +5. **Submit** pull request with clear description +6. **Address** review feedback +7. **Merge** after approval + +### Code Review Checklist +- [ ] Code follows project standards +- [ ] Tests cover new functionality +- [ ] Documentation is updated +- [ ] No breaking changes without migration +- [ ] Performance impact considered +- [ ] Security implications reviewed + +### Issue Reporting +- Use issue templates +- Provide reproduction steps +- Include environment details +- Add relevant logs/screenshots +- Label appropriately diff --git a/.cursor/rules/form-components.mdc b/.cursor/rules/form-components.mdc new file mode 100644 index 000000000..665ccfd98 --- /dev/null +++ b/.cursor/rules/form-components.mdc @@ -0,0 +1,452 @@ +--- +description: Enhanced form components with built-in authorization system +globs: resources/views/**/*.blade.php, app/View/Components/Forms/*.php +alwaysApply: true +--- + +# Enhanced Form Components with Authorization + +## Overview + +Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency. + +## Enhanced Components + +All form components now support the `canGate` authorization system: + +- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields +- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components +- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas +- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components +- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons + +## Authorization Parameters + +### Core Parameters +```php +public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete' +public mixed $canResource = null; // Resource model instance to check against +public bool $autoDisable = true; // Automatically disable if no permission +``` + +### How It Works +```php +// Automatic authorization logic in each component +if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + // For Checkbox: also sets $this->instantSave = false; + } +} +``` + +## Usage Patterns + +### ✅ Recommended: Single Line Pattern + +**Before (Verbose, 6+ lines per element):** +```html +@can('update', $application) + + + Save +@else + + +@endcan +``` + +**After (Clean, 1 line per element):** +```html + + +Save +``` + +**Result: 90% code reduction!** + +### Component-Specific Examples + +#### Input Fields +```html + + + + + + + + +``` + +#### Select Dropdowns +```html + + + + + + + + + + @foreach($servers as $server) + + @endforeach + +``` + +#### Checkboxes with InstantSave +```html + + + + + + + + +``` + +#### Textareas +```html + + + + + +``` + +#### Buttons +```html + + + Save Configuration + + + + + Deploy Application + + + + + Delete Application + +``` + +## Advanced Usage + +### Custom Authorization Logic +```html + + +``` + +### Multiple Permission Checks +```html + + +``` + +### Conditional Resources +```html + + + {{ $isEditing ? 'Save Changes' : 'View Details' }} + +``` + +## Supported Gates + +### Resource-Level Gates +- `view` - Read access to resource details +- `update` - Modify resource configuration and settings +- `deploy` - Deploy, restart, or manage resource state +- `delete` - Remove or destroy resource +- `clone` - Duplicate resource to another location + +### Global Gates +- `createAnyResource` - Create new resources of any type +- `manageTeam` - Team administration permissions +- `accessServer` - Server-level access permissions + +## Supported Resources + +### Primary Resources +- `$application` - Application instances and configurations +- `$service` - Docker Compose services and components +- `$database` - Database instances (PostgreSQL, MySQL, etc.) +- `$server` - Physical or virtual server instances + +### Container Resources +- `$project` - Project containers and environments +- `$environment` - Environment-specific configurations +- `$team` - Team and organization contexts + +### Infrastructure Resources +- `$privateKey` - SSH private keys and certificates +- `$source` - Git sources and repositories +- `$destination` - Deployment destinations and targets + +## Component Behavior + +### Input Components (Input, Select, Textarea) +When authorization fails: +- **disabled = true** - Field becomes non-editable +- **Visual styling** - Opacity reduction and disabled cursor +- **Form submission** - Values are ignored in forms +- **User feedback** - Clear visual indication of restricted access + +### Checkbox Components +When authorization fails: +- **disabled = true** - Checkbox becomes non-clickable +- **instantSave = false** - Automatic saving is disabled +- **State preservation** - Current value is maintained but read-only +- **Visual styling** - Disabled appearance with reduced opacity + +### Button Components +When authorization fails: +- **disabled = true** - Button becomes non-clickable +- **Event blocking** - Click handlers are ignored +- **Visual styling** - Disabled appearance and cursor +- **Loading states** - Loading indicators are disabled + +## Migration Guide + +### Converting Existing Forms + +**Old Pattern:** +```html +
+ @can('update', $application) + + ... + + Save + @else + + ... + + @endcan + +``` + +**New Pattern:** +```html +
+ + ... + + Save + +``` + +### Gradual Migration Strategy + +1. **Start with new forms** - Use the new pattern for all new components +2. **Convert high-traffic areas** - Migrate frequently used forms first +3. **Batch convert similar forms** - Group similar authorization patterns +4. **Test thoroughly** - Verify authorization behavior matches expectations +5. **Remove old patterns** - Clean up legacy @can/@else blocks + +## Testing Patterns + +### Component Authorization Tests +```php +// Test authorization integration in components +test('input component respects authorization', function () { + $user = User::factory()->member()->create(); + $application = Application::factory()->create(); + + // Member should see disabled input + $component = Livewire::actingAs($user) + ->test(TestComponent::class, [ + 'canGate' => 'update', + 'canResource' => $application + ]); + + expect($component->get('disabled'))->toBeTrue(); +}); + +test('checkbox disables instantSave for unauthorized users', function () { + $user = User::factory()->member()->create(); + $application = Application::factory()->create(); + + $component = Livewire::actingAs($user) + ->test(CheckboxComponent::class, [ + 'instantSave' => true, + 'canGate' => 'update', + 'canResource' => $application + ]); + + expect($component->get('disabled'))->toBeTrue(); + expect($component->get('instantSave'))->toBeFalse(); +}); +``` + +### Integration Tests +```php +// Test full form authorization behavior +test('application form respects member permissions', function () { + $member = User::factory()->member()->create(); + $application = Application::factory()->create(); + + $this->actingAs($member) + ->get(route('application.edit', $application)) + ->assertSee('disabled') + ->assertDontSee('Save Configuration'); +}); +``` + +## Best Practices + +### Consistent Gate Usage +- Use `update` for configuration changes +- Use `deploy` for operational actions +- Use `view` for read-only access +- Use `delete` for destructive actions + +### Resource Context +- Always pass the specific resource being acted upon +- Use team context for creation permissions +- Consider nested resource relationships + +### Error Handling +- Provide clear feedback for disabled components +- Use helper text to explain permission requirements +- Consider tooltips for disabled buttons + +### Performance +- Authorization checks are cached per request +- Use eager loading for resource relationships +- Consider query optimization for complex permissions + +## Common Patterns + +### Application Configuration Forms +```html + + +... + +Save +``` + +### Service Configuration Forms +```html + + + + +Save + + + + + +@can('update', $service) + +@endcan +``` + +### Server Management Forms +```html + + +... +Delete Server +``` + +### Resource Creation Forms +```html + + +... +Create Application +``` \ No newline at end of file diff --git a/.cursor/rules/frontend-patterns.mdc b/.cursor/rules/frontend-patterns.mdc new file mode 100644 index 000000000..663490d3b --- /dev/null +++ b/.cursor/rules/frontend-patterns.mdc @@ -0,0 +1,354 @@ +--- +description: Livewire components, Alpine.js patterns, Tailwind CSS, and enhanced form components +globs: app/Livewire/**/*.php, resources/views/**/*.blade.php, resources/js/**/*.js, resources/css/**/*.css +alwaysApply: false +--- +# Coolify Frontend Architecture & Patterns + +## Frontend Philosophy + +Coolify uses a **server-side first** approach with minimal JavaScript, leveraging Livewire for reactivity and Alpine.js for lightweight client-side interactions. + +## Core Frontend Stack + +### Livewire 3.5+ (Primary Framework) +- **Server-side rendering** with reactive components +- **Real-time updates** without page refreshes +- **State management** handled on the server +- **WebSocket integration** for live updates + +### Alpine.js (Client-Side Interactivity) +- **Lightweight JavaScript** for DOM manipulation +- **Declarative directives** in HTML +- **Component-like behavior** without build steps +- **Perfect companion** to Livewire + +### Tailwind CSS 4.1+ (Styling) +- **Utility-first** CSS framework +- **Custom design system** for deployment platform +- **Responsive design** built-in +- **Dark mode support** + +## Livewire Component Structure + +### Location: [app/Livewire/](mdc:app/Livewire) + +#### Core Application Components +- **[Dashboard.php](mdc:app/Livewire/Dashboard.php)** - Main dashboard interface +- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Real-time activity tracking +- **[MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php)** - Code editor component + +#### Server Management +- **Server/** directory - Server configuration and monitoring +- Real-time server status updates +- SSH connection management +- Resource monitoring + +#### Project & Application Management +- **Project/** directory - Project organization +- Application deployment interfaces +- Environment variable management +- Service configuration + +#### Settings & Configuration +- **Settings/** directory - System configuration +- **[SettingsEmail.php](mdc:app/Livewire/SettingsEmail.php)** - Email notification setup +- **[SettingsOauth.php](mdc:app/Livewire/SettingsOauth.php)** - OAuth provider configuration +- **[SettingsBackup.php](mdc:app/Livewire/SettingsBackup.php)** - Backup configuration + +#### User & Team Management +- **Team/** directory - Team collaboration features +- **Profile/** directory - User profile management +- **Security/** directory - Security settings + +## Blade Template Organization + +### Location: [resources/views/](mdc:resources/views) + +#### Layout Structure +- **layouts/** - Base layout templates +- **components/** - Reusable UI components +- **livewire/** - Livewire component views + +#### Feature-Specific Views +- **server/** - Server management interfaces +- **auth/** - Authentication pages +- **emails/** - Email templates +- **errors/** - Error pages + +## Interactive Components + +### Monaco Editor Integration +- **Code editing** for configuration files +- **Syntax highlighting** for multiple languages +- **Live validation** and error detection +- **Integration** with deployment process + +### Terminal Emulation (XTerm.js) +- **Real-time terminal** access to servers +- **WebSocket-based** communication +- **Multi-session** support +- **Secure connection** through SSH + +### Real-Time Updates +- **WebSocket connections** via Laravel Echo +- **Live deployment logs** streaming +- **Server monitoring** with live metrics +- **Activity notifications** in real-time + +## Alpine.js Patterns + +### Common Directives Used +```html + +
+ + + +``` + +## Tailwind CSS Patterns + +### Design System +- **Consistent spacing** using Tailwind scale +- **Color palette** optimized for deployment platform +- **Typography** hierarchy for technical content +- **Component classes** for reusable elements + +### Responsive Design +```html + +
+ +
+``` + +### Dark Mode Support +```html + +
+ +
+``` + +## Build Process + +### Vite Configuration ([vite.config.js](mdc:vite.config.js)) +- **Fast development** with hot module replacement +- **Optimized production** builds +- **Asset versioning** for cache busting +- **CSS processing** with PostCSS + +### Asset Compilation +```bash +# Development +npm run dev + +# Production build +npm run build +``` + +## State Management Patterns + +### Server-Side State (Livewire) +- **Component properties** for persistent state +- **Session storage** for user preferences +- **Database models** for application state +- **Cache layer** for performance + +### Client-Side State (Alpine.js) +- **Local component state** for UI interactions +- **Form validation** and user feedback +- **Modal and dropdown** state management +- **Temporary UI states** (loading, hover, etc.) + +## Real-Time Features + +### WebSocket Integration +```php +// Livewire component with real-time updates +class ActivityMonitor extends Component +{ + public function getListeners() + { + return [ + 'deployment.started' => 'refresh', + 'deployment.finished' => 'refresh', + 'server.status.changed' => 'updateServerStatus', + ]; + } +} +``` + +### Event Broadcasting +- **Laravel Echo** for client-side WebSocket handling +- **Pusher protocol** for real-time communication +- **Private channels** for user-specific events +- **Presence channels** for collaborative features + +## Performance Patterns + +### Lazy Loading +```php +// Livewire lazy loading +class ServerList extends Component +{ + public function placeholder() + { + return view('components.loading-skeleton'); + } +} +``` + +### Caching Strategies +- **Fragment caching** for expensive operations +- **Image optimization** with lazy loading +- **Asset bundling** and compression +- **CDN integration** for static assets + +## Enhanced Form Components + +### Built-in Authorization System +Coolify features **enhanced form components** with automatic authorization handling: + +```html + + + +Save + + +@can('update', $application) + +@else + +@endcan +``` + +### Authorization Parameters +```php +// Available on all form components (Input, Select, Textarea, Checkbox, Button) +public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete' +public mixed $canResource = null; // Resource model instance to check against +public bool $autoDisable = true; // Automatically disable if no permission (default: true) +``` + +### Benefits +- **90% code reduction** for authorization-protected forms +- **Consistent security** across all form components +- **Automatic disabling** for unauthorized users +- **Smart behavior** (disables instantSave on checkboxes for unauthorized users) + +For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** + +## Form Handling Patterns + +### Livewire Forms +```php +class ServerCreateForm extends Component +{ + public $name; + public $ip; + + protected $rules = [ + 'name' => 'required|min:3', + 'ip' => 'required|ip', + ]; + + public function save() + { + $this->validate(); + // Save logic + } +} +``` + +### Real-Time Validation +- **Live validation** as user types +- **Server-side validation** rules +- **Error message** display +- **Success feedback** patterns + +## Component Communication + +### Parent-Child Communication +```php +// Parent component +$this->emit('serverCreated', $server->id); + +// Child component +protected $listeners = ['serverCreated' => 'refresh']; +``` + +### Cross-Component Events +- **Global events** for application-wide updates +- **Scoped events** for feature-specific communication +- **Browser events** for JavaScript integration + +## Error Handling & UX + +### Loading States +- **Skeleton screens** during data loading +- **Progress indicators** for long operations +- **Optimistic updates** with rollback capability + +### Error Display +- **Toast notifications** for user feedback +- **Inline validation** errors +- **Global error** handling +- **Retry mechanisms** for failed operations + +## Accessibility Patterns + +### ARIA Labels and Roles +```html + +``` + +### Keyboard Navigation +- **Tab order** management +- **Keyboard shortcuts** for power users +- **Focus management** in modals and forms +- **Screen reader** compatibility + +## Mobile Optimization + +### Touch-Friendly Interface +- **Larger tap targets** for mobile devices +- **Swipe gestures** where appropriate +- **Mobile-optimized** forms and navigation + +### Progressive Enhancement +- **Core functionality** works without JavaScript +- **Enhanced experience** with JavaScript enabled +- **Offline capabilities** where possible diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc new file mode 100644 index 000000000..c409a4647 --- /dev/null +++ b/.cursor/rules/laravel-boost.mdc @@ -0,0 +1,405 @@ +--- +alwaysApply: true +--- + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.7 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/socialite (SOCIALITE) - v5 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 +- laravel/pint (PINT) - v1 +- laravel/telescope (TELESCOPE) - v5 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- rector/rector (RECTOR) - v2 +- laravel-echo (ECHO) - v2 +- tailwindcss (TAILWINDCSS) - v4 +- vue (VUE) - v3 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 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 Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 000000000..b615a5d3e --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,161 @@ +--- +description: High-level project mission, core concepts, and architectural overview +globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md +alwaysApply: false +--- +# Coolify Project Overview + +## What is Coolify? + +Coolify is an **open-source & self-hostable alternative to Heroku / Netlify / Vercel**. It's a comprehensive deployment platform that helps you manage servers, applications, and databases on your own hardware with just an SSH connection. + +## Core Mission + +**"Imagine having the ease of a cloud but with your own servers. That is Coolify."** + +- **No vendor lock-in** - All configurations saved to your servers +- **Self-hosted** - Complete control over your infrastructure +- **SSH-only requirement** - Works with VPS, Bare Metal, Raspberry PIs, anything +- **Docker-first** - Container-based deployment architecture + +## Key Features + +### 🚀 **Application Deployment** +- Git-based deployments (GitHub, GitLab, Bitbucket, Gitea) +- Docker & Docker Compose support +- Preview deployments for pull requests +- Zero-downtime deployments +- Build cache optimization + +### 🖥️ **Server Management** +- Multi-server orchestration +- Real-time monitoring and logs +- SSH key management +- Proxy configuration (Traefik/Caddy) +- Resource usage tracking + +### 🗄️ **Database Management** +- PostgreSQL, MySQL, MariaDB, MongoDB +- Redis, KeyDB, Dragonfly, ClickHouse +- Automated backups with S3 integration +- Database clustering support + +### 🔧 **Infrastructure as Code** +- Docker Compose generation +- Environment variable management +- SSL certificate automation +- Custom domain configuration + +### 👥 **Team Collaboration** +- Multi-tenant team organization +- Role-based access control +- Project and environment isolation +- Team-wide resource sharing + +### 📊 **Monitoring & Observability** +- Real-time application logs +- Server resource monitoring +- Deployment status tracking +- Webhook integrations +- Notification systems (Email, Discord, Slack, Telegram) + +## Target Users + +### **DevOps Engineers** +- Infrastructure automation +- Multi-environment management +- CI/CD pipeline integration + +### **Developers** +- Easy application deployment +- Development environment provisioning +- Preview deployments for testing + +### **Small to Medium Businesses** +- Cost-effective Heroku alternative +- Self-hosted control and privacy +- Scalable infrastructure management + +### **Agencies & Consultants** +- Client project isolation +- Multi-tenant management +- White-label deployment solutions + +## Business Model + +### **Open Source (Free)** +- Complete feature set +- Self-hosted deployment +- Community support +- No feature restrictions + +### **Cloud Version (Paid)** +- Managed Coolify instance +- High availability +- Premium support +- Email notifications included +- Same price as self-hosted server (~$4-5/month) + +## Architecture Philosophy + +### **Server-Side First** +- Laravel backend with Livewire frontend +- Minimal JavaScript footprint +- Real-time updates via WebSockets +- Progressive enhancement approach + +### **Docker-Native** +- Container-first deployment strategy +- Docker Compose orchestration +- Image building and registry integration +- Volume and network management + +### **Security-Focused** +- SSH-based server communication +- Environment variable encryption +- Team-based access isolation +- Audit logging and activity tracking + +## Project Structure + +``` +coolify/ +├── app/ # Laravel application core +│ ├── Models/ # Domain models (Application, Server, Service) +│ ├── Livewire/ # Frontend components +│ ├── Actions/ # Business logic actions +│ └── Jobs/ # Background job processing +├── resources/ # Frontend assets and views +├── database/ # Migrations and seeders +├── docker/ # Docker configuration +├── scripts/ # Installation and utility scripts +└── tests/ # Test suites (Pest, Dusk) +``` + +## Key Differentiators + +### **vs. Heroku** +- ✅ Self-hosted (no vendor lock-in) +- ✅ Multi-server support +- ✅ No usage-based pricing +- ✅ Full infrastructure control + +### **vs. Vercel/Netlify** +- ✅ Backend application support +- ✅ Database management included +- ✅ Multi-environment workflows +- ✅ Custom server infrastructure + +### **vs. Docker Swarm/Kubernetes** +- ✅ User-friendly web interface +- ✅ Git-based deployment workflows +- ✅ Integrated monitoring and logging +- ✅ No complex YAML configuration + +## Development Principles + +- **Simplicity over complexity** +- **Convention over configuration** +- **Security by default** +- **Developer experience focused** +- **Community-driven development** diff --git a/.cursor/rules/security-patterns.mdc b/.cursor/rules/security-patterns.mdc new file mode 100644 index 000000000..a7ab2ad69 --- /dev/null +++ b/.cursor/rules/security-patterns.mdc @@ -0,0 +1,1105 @@ +--- +description: Security architecture, authentication, authorization patterns, and enhanced form component security +globs: app/Policies/*.php, app/View/Components/Forms/*.php, app/Http/Middleware/*.php, resources/views/**/*.blade.php +alwaysApply: true +--- +# Coolify Security Architecture & Patterns + +## Security Philosophy + +Coolify implements **defense-in-depth security** with multiple layers of protection including authentication, authorization, encryption, network isolation, and secure deployment practices. + +## Authentication Architecture + +### Multi-Provider Authentication +- **[Laravel Fortify](mdc:config/fortify.php)** - Core authentication scaffolding (4.9KB, 149 lines) +- **[Laravel Sanctum](mdc:config/sanctum.php)** - API token authentication (2.4KB, 69 lines) +- **[Laravel Socialite](mdc:config/services.php)** - OAuth provider integration + +### OAuth Integration +- **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations +- **Supported Providers**: + - Google OAuth + - Microsoft Azure AD + - Clerk + - Authentik + - Discord + - GitHub (via GitHub Apps) + - GitLab + +### Authentication Models +```php +// User authentication with team-based access +class User extends Authenticatable +{ + use HasApiTokens, HasFactory, Notifiable; + + protected $fillable = [ + 'name', 'email', 'password' + ]; + + protected $hidden = [ + 'password', 'remember_token' + ]; + + protected $casts = [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function currentTeam(): BelongsTo + { + return $this->belongsTo(Team::class, 'current_team_id'); + } +} +``` + +## Authorization & Access Control + +### Enhanced Form Component Authorization System + +Coolify now features a **centralized authorization system** built into all form components (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`) that automatically handles permission-based UI control. + +#### Component Authorization Parameters +```php +// Available on all form components +public ?string $canGate = null; // Gate name (e.g., 'update', 'view', 'delete') +public mixed $canResource = null; // Resource to check against (model instance) +public bool $autoDisable = true; // Auto-disable if no permission (default: true) +``` + +#### Smart Authorization Logic +```php +// Automatic authorization handling in component constructor +if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + // For Checkbox: also disables instantSave + } +} +``` + +#### Usage Examples + +**✅ Recommended Pattern (Single Line):** +```html + + + + + + + + + + + + + + + Save Configuration + +``` + +**❌ Old Pattern (Verbose, Deprecated):** +```html + +@can('update', $application) + + Save +@else + +@endcan +``` + +#### Advanced Usage with Custom Control + +**Custom Authorization Logic:** +```html + + +``` + +**Multiple Permission Checks:** +```html + + +``` + +#### Supported Gates and Resources + +**Common Gates:** +- `view` - Read access to resource +- `update` - Modify resource configuration +- `deploy` - Deploy/restart resource +- `delete` - Remove resource +- `createAnyResource` - Create new resources + +**Resource Types:** +- `Application` - Application instances +- `Service` - Docker Compose services +- `Server` - Server instances +- `Project` - Project containers +- `Environment` - Environment contexts +- `Database` - Database instances + +#### Benefits + +**🔥 Massive Code Reduction:** +- **90% less code** for authorization-protected forms +- **Single line** instead of 6-12 lines per form element +- **No more @can/@else blocks** cluttering templates + +**🛡️ Consistent Security:** +- **Unified authorization logic** across all form components +- **Automatic disabling** for unauthorized users +- **Smart behavior** (like disabling instantSave on checkboxes) + +**🎨 Better UX:** +- **Consistent disabled styling** across all components +- **Proper visual feedback** for restricted access +- **Clean, professional interface** + +#### Implementation Details + +**Component Enhancement:** +```php +// Enhanced in all form components +use Illuminate\Support\Facades\Gate; + +public function __construct( + // ... existing parameters + public ?string $canGate = null, + public mixed $canResource = null, + public bool $autoDisable = true, +) { + // Handle authorization-based disabling + if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + // For Checkbox: $this->instantSave = false; + } + } +} +``` + +**Backward Compatibility:** +- All existing form components continue to work unchanged +- New authorization parameters are optional +- Legacy @can/@else patterns still function but are discouraged + +### Custom Component Authorization Patterns + +When dealing with **custom Alpine.js components** or complex UI elements that don't use the standard `x-forms.*` components, manual authorization protection is required since the automatic `canGate` system only applies to enhanced form components. + +#### Common Custom Components Requiring Manual Protection + +**⚠️ Custom Components That Need Manual Authorization:** +- Custom dropdowns/selects with Alpine.js +- Complex form widgets with JavaScript interactions +- Multi-step wizards or dynamic forms +- Third-party component integrations +- Custom date/time pickers +- File upload components with drag-and-drop + +#### Manual Authorization Pattern + +**✅ Proper Manual Authorization:** +```html + +
+
+ + +
+ @can('update', $resource) + +
+ +
+ + +
+
+ @else + +
+ + + + +
+ @endcan +
+``` + +#### Implementation Checklist + +When implementing authorization for custom components: + +**🔍 1. Identify Custom Components:** +- Look for Alpine.js `x-data` declarations +- Find components not using `x-forms.*` prefix +- Check for JavaScript-heavy interactions +- Review complex form widgets + +**🛡️ 2. Wrap with Authorization:** +- Use `@can('gate', $resource)` / `@else` / `@endcan` structure +- Provide full functionality in the `@can` block +- Create disabled/readonly version in the `@else` block + +**🎨 3. Design Disabled State:** +- Apply `readonly disabled` attributes to inputs +- Add `opacity-50 cursor-not-allowed` classes for visual feedback +- Remove interactive JavaScript behaviors +- Show current value or appropriate placeholder + +**🔒 4. Backend Protection:** +- Ensure corresponding Livewire methods check authorization +- Add `$this->authorize('gate', $resource)` in relevant methods +- Validate permissions before processing any changes + +#### Real-World Examples + +**Custom Date Range Picker:** +```html +@can('update', $application) +
+ +
+@else +
+ + +
+@endcan +``` + +**Multi-Select Component:** +```html +@can('update', $server) +
+ +
+@else +
+ @foreach($selectedValues as $value) +
+ {{ $value }} +
+ @endforeach +
+@endcan +``` + +**File Upload Widget:** +```html +@can('update', $application) +
+ +
+@else +
+

File upload restricted

+ @if($currentFile) +

Current: {{ $currentFile }}

+ @endif +
+@endcan +``` + +#### Key Principles + +**🎯 Consistency:** +- Maintain similar visual styling between enabled/disabled states +- Use consistent disabled patterns across the application +- Apply the same opacity and cursor styling + +**🔐 Security First:** +- Always implement backend authorization checks +- Never rely solely on frontend hiding/disabling +- Validate permissions on every server action + +**💡 User Experience:** +- Show current values in disabled state when appropriate +- Provide clear visual feedback about restricted access +- Maintain layout stability between states + +**🚀 Performance:** +- Minimize Alpine.js initialization for disabled components +- Avoid loading unnecessary JavaScript for unauthorized users +- Use simple HTML structures for read-only states + +### Team-Based Multi-Tenancy +- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines) +- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration +- **Role-based permissions** within teams +- **Resource isolation** by team ownership + +### Authorization Patterns +```php +// Team-scoped authorization middleware +class EnsureTeamAccess +{ + public function handle(Request $request, Closure $next): Response + { + $user = $request->user(); + $teamId = $request->route('team'); + + if (!$user->teams->contains('id', $teamId)) { + abort(403, 'Access denied to team resources'); + } + + // Set current team context + $user->switchTeam($teamId); + + return $next($request); + } +} + +// Resource-level authorization policies +class ApplicationPolicy +{ + public function view(User $user, Application $application): bool + { + return $user->teams->contains('id', $application->team_id); + } + + public function deploy(User $user, Application $application): bool + { + return $this->view($user, $application) && + $user->hasTeamPermission($application->team_id, 'deploy'); + } + + public function delete(User $user, Application $application): bool + { + return $this->view($user, $application) && + $user->hasTeamRole($application->team_id, 'admin'); + } +} +``` + +### Global Scopes for Data Isolation +```php +// Automatic team-based filtering +class Application extends Model +{ + protected static function booted(): void + { + static::addGlobalScope('team', function (Builder $builder) { + if (auth()->check() && auth()->user()->currentTeam) { + $builder->whereHas('environment.project', function ($query) { + $query->where('team_id', auth()->user()->currentTeam->id); + }); + } + }); + } +} +``` + +## API Security + +### Token-Based Authentication +```php +// Sanctum API token management +class PersonalAccessToken extends Model +{ + protected $fillable = [ + 'name', 'token', 'abilities', 'expires_at' + ]; + + protected $casts = [ + 'abilities' => 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + ]; + + public function tokenable(): MorphTo + { + return $this->morphTo(); + } + + public function hasAbility(string $ability): bool + { + return in_array('*', $this->abilities) || + in_array($ability, $this->abilities); + } +} +``` + +### API Rate Limiting +```php +// Rate limiting configuration +RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); +}); + +RateLimiter::for('deployments', function (Request $request) { + return Limit::perMinute(10)->by($request->user()->id); +}); + +RateLimiter::for('webhooks', function (Request $request) { + return Limit::perMinute(100)->by($request->ip()); +}); +``` + +### API Input Validation +```php +// Comprehensive input validation +class StoreApplicationRequest extends FormRequest +{ + public function authorize(): bool + { + return $this->user()->can('create', Application::class); + } + + public function rules(): array + { + return [ + 'name' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/', + 'git_repository' => 'required|url|starts_with:https://', + 'git_branch' => 'required|string|max:100|regex:/^[a-zA-Z0-9\-_\/]+$/', + 'server_id' => 'required|exists:servers,id', + 'environment_id' => 'required|exists:environments,id', + 'environment_variables' => 'array', + 'environment_variables.*' => 'string|max:1000', + ]; + } + + public function prepareForValidation(): void + { + $this->merge([ + 'name' => strip_tags($this->name), + 'git_repository' => filter_var($this->git_repository, FILTER_SANITIZE_URL), + ]); + } +} +``` + +## SSH Security + +### Private Key Management +- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - Secure SSH key storage (6.5KB, 247 lines) +- **Encrypted key storage** in database +- **Key rotation** capabilities +- **Access logging** for key usage + +### SSH Connection Security +```php +class SshConnection +{ + private string $host; + private int $port; + private string $username; + private PrivateKey $privateKey; + + public function __construct(Server $server) + { + $this->host = $server->ip; + $this->port = $server->port; + $this->username = $server->user; + $this->privateKey = $server->privateKey; + } + + public function connect(): bool + { + $connection = ssh2_connect($this->host, $this->port); + + if (!$connection) { + throw new SshConnectionException('Failed to connect to server'); + } + + // Use private key authentication + $privateKeyContent = decrypt($this->privateKey->private_key); + $publicKeyContent = decrypt($this->privateKey->public_key); + + if (!ssh2_auth_pubkey_file($connection, $this->username, $publicKeyContent, $privateKeyContent)) { + throw new SshAuthenticationException('SSH authentication failed'); + } + + return true; + } + + public function execute(string $command): string + { + // Sanitize command to prevent injection + $command = escapeshellcmd($command); + + $stream = ssh2_exec($this->connection, $command); + + if (!$stream) { + throw new SshExecutionException('Failed to execute command'); + } + + return stream_get_contents($stream); + } +} +``` + +## Container Security + +### Docker Security Patterns +```php +class DockerSecurityService +{ + public function createSecureContainer(Application $application): array + { + return [ + 'image' => $this->validateImageName($application->docker_image), + 'user' => '1000:1000', // Non-root user + 'read_only' => true, + 'no_new_privileges' => true, + 'security_opt' => [ + 'no-new-privileges:true', + 'apparmor:docker-default' + ], + 'cap_drop' => ['ALL'], + 'cap_add' => ['CHOWN', 'SETUID', 'SETGID'], // Minimal capabilities + 'tmpfs' => [ + '/tmp' => 'rw,noexec,nosuid,size=100m', + '/var/tmp' => 'rw,noexec,nosuid,size=50m' + ], + 'ulimits' => [ + 'nproc' => 1024, + 'nofile' => 1024 + ] + ]; + } + + private function validateImageName(string $image): string + { + // Validate image name against allowed registries + $allowedRegistries = ['docker.io', 'ghcr.io', 'quay.io']; + + $parser = new DockerImageParser(); + $parsed = $parser->parse($image); + + if (!in_array($parsed['registry'], $allowedRegistries)) { + throw new SecurityException('Image registry not allowed'); + } + + return $image; + } +} +``` + +### Network Isolation +```yaml +# Docker Compose security configuration +version: '3.8' +services: + app: + image: ${APP_IMAGE} + networks: + - app-network + security_opt: + - no-new-privileges:true + - apparmor:docker-default + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=100m + cap_drop: + - ALL + cap_add: + - CHOWN + - SETUID + - SETGID + +networks: + app-network: + driver: bridge + internal: true + ipam: + config: + - subnet: 172.20.0.0/16 +``` + +## SSL/TLS Security + +### Certificate Management +- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation +- **Let's Encrypt** integration for free certificates +- **Automatic renewal** and monitoring +- **Custom certificate** upload support + +### SSL Configuration +```php +class SslCertificateService +{ + public function generateCertificate(Application $application): SslCertificate + { + $domains = $this->validateDomains($application->getAllDomains()); + + $certificate = SslCertificate::create([ + 'application_id' => $application->id, + 'domains' => $domains, + 'provider' => 'letsencrypt', + 'status' => 'pending' + ]); + + // Generate certificate using ACME protocol + $acmeClient = new AcmeClient(); + $certData = $acmeClient->generateCertificate($domains); + + $certificate->update([ + 'certificate' => encrypt($certData['certificate']), + 'private_key' => encrypt($certData['private_key']), + 'chain' => encrypt($certData['chain']), + 'expires_at' => $certData['expires_at'], + 'status' => 'active' + ]); + + return $certificate; + } + + private function validateDomains(array $domains): array + { + foreach ($domains as $domain) { + if (!filter_var($domain, FILTER_VALIDATE_DOMAIN)) { + throw new InvalidDomainException("Invalid domain: {$domain}"); + } + + // Check domain ownership + if (!$this->verifyDomainOwnership($domain)) { + throw new DomainOwnershipException("Domain ownership verification failed: {$domain}"); + } + } + + return $domains; + } +} +``` + +## Environment Variable Security + +### Secure Configuration Management +```php +class EnvironmentVariable extends Model +{ + protected $fillable = [ + 'key', 'value', 'is_secret', 'application_id' + ]; + + protected $casts = [ + 'is_secret' => 'boolean', + 'value' => 'encrypted' // Automatic encryption for sensitive values + ]; + + public function setValueAttribute($value): void + { + // Automatically encrypt sensitive environment variables + if ($this->isSensitiveKey($this->key)) { + $this->attributes['value'] = encrypt($value); + $this->attributes['is_secret'] = true; + } else { + $this->attributes['value'] = $value; + } + } + + public function getValueAttribute($value): string + { + if ($this->is_secret) { + return decrypt($value); + } + + return $value; + } + + private function isSensitiveKey(string $key): bool + { + $sensitivePatterns = [ + 'PASSWORD', 'SECRET', 'KEY', 'TOKEN', 'API_KEY', + 'DATABASE_URL', 'REDIS_URL', 'PRIVATE', 'CREDENTIAL', + 'AUTH', 'CERTIFICATE', 'ENCRYPTION', 'SALT', 'HASH', + 'OAUTH', 'JWT', 'BEARER', 'ACCESS', 'REFRESH' + ]; + + foreach ($sensitivePatterns as $pattern) { + if (str_contains(strtoupper($key), $pattern)) { + return true; + } + } + + return false; + } +} +``` + +## Webhook Security + +### Webhook Signature Verification +```php +class WebhookSecurityService +{ + public function verifyGitHubSignature(Request $request, string $secret): bool + { + $signature = $request->header('X-Hub-Signature-256'); + + if (!$signature) { + return false; + } + + $expectedSignature = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret); + + return hash_equals($expectedSignature, $signature); + } + + public function verifyGitLabSignature(Request $request, string $secret): bool + { + $signature = $request->header('X-Gitlab-Token'); + + return hash_equals($secret, $signature); + } + + public function validateWebhookPayload(array $payload): array + { + // Sanitize and validate webhook payload + $validator = Validator::make($payload, [ + 'repository.clone_url' => 'required|url|starts_with:https://', + 'ref' => 'required|string|max:255', + 'head_commit.id' => 'required|string|size:40', // Git SHA + 'head_commit.message' => 'required|string|max:1000' + ]); + + if ($validator->fails()) { + throw new InvalidWebhookPayloadException('Invalid webhook payload'); + } + + return $validator->validated(); + } +} +``` + +## Input Sanitization & Validation + +### XSS Prevention +```php +class SecurityMiddleware +{ + public function handle(Request $request, Closure $next): Response + { + // Sanitize input data + $input = $request->all(); + $sanitized = $this->sanitizeInput($input); + $request->merge($sanitized); + + return $next($request); + } + + private function sanitizeInput(array $input): array + { + foreach ($input as $key => $value) { + if (is_string($value)) { + // Remove potentially dangerous HTML tags + $input[$key] = strip_tags($value, '


'); + + // Escape special characters + $input[$key] = htmlspecialchars($input[$key], ENT_QUOTES, 'UTF-8'); + } elseif (is_array($value)) { + $input[$key] = $this->sanitizeInput($value); + } + } + + return $input; + } +} +``` + +### SQL Injection Prevention +```php +// Always use parameterized queries and Eloquent ORM +class ApplicationRepository +{ + public function findByName(string $name): ?Application + { + // Safe: Uses parameter binding + return Application::where('name', $name)->first(); + } + + public function searchApplications(string $query): Collection + { + // Safe: Eloquent handles escaping + return Application::where('name', 'LIKE', "%{$query}%") + ->orWhere('description', 'LIKE', "%{$query}%") + ->get(); + } + + // NEVER do this - vulnerable to SQL injection + // public function unsafeSearch(string $query): Collection + // { + // return DB::select("SELECT * FROM applications WHERE name LIKE '%{$query}%'"); + // } +} +``` + +## Audit Logging & Monitoring + +### Activity Logging +```php +// Using Spatie Activity Log package +class Application extends Model +{ + use LogsActivity; + + protected static $logAttributes = [ + 'name', 'git_repository', 'git_branch', 'fqdn' + ]; + + protected static $logOnlyDirty = true; + + public function getDescriptionForEvent(string $eventName): string + { + return "Application {$this->name} was {$eventName}"; + } +} + +// Custom security events +class SecurityEventLogger +{ + public function logFailedLogin(string $email, string $ip): void + { + activity('security') + ->withProperties([ + 'email' => $email, + 'ip' => $ip, + 'user_agent' => request()->userAgent() + ]) + ->log('Failed login attempt'); + } + + public function logSuspiciousActivity(User $user, string $activity): void + { + activity('security') + ->causedBy($user) + ->withProperties([ + 'activity' => $activity, + 'ip' => request()->ip(), + 'timestamp' => now() + ]) + ->log('Suspicious activity detected'); + } +} +``` + +### Security Monitoring +```php +class SecurityMonitoringService +{ + public function detectAnomalousActivity(User $user): bool + { + // Check for unusual login patterns + $recentLogins = $user->activities() + ->where('description', 'like', '%login%') + ->where('created_at', '>=', now()->subHours(24)) + ->get(); + + // Multiple failed attempts + $failedAttempts = $recentLogins->where('description', 'Failed login attempt')->count(); + if ($failedAttempts > 5) { + $this->triggerSecurityAlert($user, 'Multiple failed login attempts'); + return true; + } + + // Login from new location + $uniqueIps = $recentLogins->pluck('properties.ip')->unique(); + if ($uniqueIps->count() > 3) { + $this->triggerSecurityAlert($user, 'Login from multiple IP addresses'); + return true; + } + + return false; + } + + private function triggerSecurityAlert(User $user, string $reason): void + { + // Send security notification + $user->notify(new SecurityAlertNotification($reason)); + + // Log security event + activity('security') + ->causedBy($user) + ->withProperties(['reason' => $reason]) + ->log('Security alert triggered'); + } +} +``` + +## Backup Security + +### Encrypted Backups +```php +class SecureBackupService +{ + public function createEncryptedBackup(ScheduledDatabaseBackup $backup): void + { + $database = $backup->database; + $dumpPath = $this->createDatabaseDump($database); + + // Encrypt backup file + $encryptedPath = $this->encryptFile($dumpPath, $backup->encryption_key); + + // Upload to secure storage + $this->uploadToSecureStorage($encryptedPath, $backup->s3Storage); + + // Clean up local files + unlink($dumpPath); + unlink($encryptedPath); + } + + private function encryptFile(string $filePath, string $key): string + { + $data = file_get_contents($filePath); + $encryptedData = encrypt($data, $key); + + $encryptedPath = $filePath . '.encrypted'; + file_put_contents($encryptedPath, $encryptedData); + + return $encryptedPath; + } +} +``` + +## Security Headers & CORS + +### Security Headers Configuration +```php +// Security headers middleware +class SecurityHeadersMiddleware +{ + public function handle(Request $request, Closure $next): Response + { + $response = $next($request); + + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'DENY'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + if ($request->secure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} +``` + +### CORS Configuration +```php +// CORS configuration for API endpoints +return [ + 'paths' => ['api/*', 'webhooks/*'], + 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + 'allowed_origins' => [ + 'https://app.coolify.io', + 'https://*.coolify.io' + ], + 'allowed_origins_patterns' => [], + 'allowed_headers' => ['*'], + 'exposed_headers' => [], + 'max_age' => 0, + 'supports_credentials' => true, +]; +``` + +## Security Testing + +### Security Test Patterns +```php +// Security-focused tests +test('prevents SQL injection in search', function () { + $user = User::factory()->create(); + $maliciousInput = "'; DROP TABLE applications; --"; + + $response = $this->actingAs($user) + ->getJson("/api/v1/applications?search={$maliciousInput}"); + + $response->assertStatus(200); + + // Verify applications table still exists + expect(Schema::hasTable('applications'))->toBeTrue(); +}); + +test('prevents XSS in application names', function () { + $user = User::factory()->create(); + $xssPayload = ''; + + $response = $this->actingAs($user) + ->postJson('/api/v1/applications', [ + 'name' => $xssPayload, + 'git_repository' => 'https://github.com/user/repo.git', + 'server_id' => Server::factory()->create()->id + ]); + + $response->assertStatus(422); +}); + +test('enforces team isolation', function () { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $team1 = Team::factory()->create(); + $team2 = Team::factory()->create(); + + $user1->teams()->attach($team1); + $user2->teams()->attach($team2); + + $application = Application::factory()->create(['team_id' => $team1->id]); + + $response = $this->actingAs($user2) + ->getJson("/api/v1/applications/{$application->id}"); + + $response->assertStatus(403); +}); +``` diff --git a/.cursor/rules/self_improve.mdc b/.cursor/rules/self_improve.mdc new file mode 100644 index 000000000..2bebaec75 --- /dev/null +++ b/.cursor/rules/self_improve.mdc @@ -0,0 +1,59 @@ +--- +description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. +globs: **/* +alwaysApply: true +--- + +- **Rule Improvement Triggers:** + - New code patterns not covered by existing rules + - Repeated similar implementations across files + - Common error patterns that could be prevented + - New libraries or tools being used consistently + - Emerging best practices in the codebase + +- **Analysis Process:** + - Compare new code with existing rules + - Identify patterns that should be standardized + - Look for references to external documentation + - Check for consistent error handling patterns + - Monitor test patterns and coverage + +- **Rule Updates:** + - **Add New Rules When:** + - A new technology/pattern is used in 3+ files + - Common bugs could be prevented by a rule + - Code reviews repeatedly mention the same feedback + - New security or performance patterns emerge + + - **Modify Existing Rules When:** + - Better examples exist in the codebase + - Additional edge cases are discovered + - Related rules have been updated + - Implementation details have changed + + +- **Rule Quality Checks:** + - Rules should be actionable and specific + - Examples should come from actual code + - References should be up to date + - Patterns should be consistently enforced + +- **Continuous Improvement:** + - Monitor code review comments + - Track common development questions + - Update rules after major refactors + - Add links to relevant documentation + - Cross-reference related rules + +- **Rule Deprecation:** + - Mark outdated patterns as deprecated + - Remove rules that no longer apply + - Update references to deprecated rules + - Document migration paths for old patterns + +- **Documentation Updates:** + - Keep examples synchronized with code + - Update references to external docs + - Maintain links between related rules + - Document breaking changes +Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure. diff --git a/.cursor/rules/technology-stack.mdc b/.cursor/rules/technology-stack.mdc new file mode 100644 index 000000000..2119a2ff1 --- /dev/null +++ b/.cursor/rules/technology-stack.mdc @@ -0,0 +1,250 @@ +--- +description: Complete technology stack, dependencies, and infrastructure components +globs: composer.json, package.json, docker-compose*.yml, config/*.php +alwaysApply: false +--- +# Coolify Technology Stack + +## Backend Framework + +### **Laravel 12.4.1** (PHP Framework) +- **Location**: [composer.json](mdc:composer.json) +- **Purpose**: Core application framework +- **Key Features**: + - Eloquent ORM for database interactions + - Artisan CLI for development tasks + - Queue system for background jobs + - Event-driven architecture + +### **PHP 8.4** +- **Requirement**: `^8.4` in [composer.json](mdc:composer.json) +- **Features Used**: + - Typed properties and return types + - Attributes for validation and configuration + - Match expressions + - Constructor property promotion + +## Frontend Stack + +### **Livewire 3.5.20** (Primary Frontend Framework) +- **Purpose**: Server-side rendering with reactive components +- **Location**: [app/Livewire/](mdc:app/Livewire/) +- **Key Components**: + - [Dashboard.php](mdc:app/Livewire/Dashboard.php) - Main interface + - [ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php) - Real-time monitoring + - [MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php) - Code editor + +### **Alpine.js** (Client-Side Interactivity) +- **Purpose**: Lightweight JavaScript for DOM manipulation +- **Integration**: Works seamlessly with Livewire components +- **Usage**: Declarative directives in Blade templates + +### **Tailwind CSS 4.1.4** (Styling Framework) +- **Location**: [package.json](mdc:package.json) +- **Configuration**: [postcss.config.cjs](mdc:postcss.config.cjs) +- **Extensions**: + - `@tailwindcss/forms` - Form styling + - `@tailwindcss/typography` - Content typography + - `tailwind-scrollbar` - Custom scrollbars + +### **Vue.js 3.5.13** (Component Framework) +- **Purpose**: Enhanced interactive components +- **Integration**: Used alongside Livewire for complex UI +- **Build Tool**: Vite with Vue plugin + +## Database & Caching + +### **PostgreSQL 15** (Primary Database) +- **Purpose**: Main application data storage +- **Features**: JSONB support, advanced indexing +- **Models**: [app/Models/](mdc:app/Models/) + +### **Redis 7** (Caching & Real-time) +- **Purpose**: + - Session storage + - Queue backend + - Real-time data caching + - WebSocket session management + +### **Supported Databases** (For User Applications) +- **PostgreSQL**: [StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php) +- **MySQL**: [StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php) +- **MariaDB**: [StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php) +- **MongoDB**: [StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php) +- **Redis**: [StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php) +- **KeyDB**: [StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php) +- **Dragonfly**: [StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php) +- **ClickHouse**: [StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php) + +## Authentication & Security + +### **Laravel Sanctum 4.0.8** +- **Purpose**: API token authentication +- **Usage**: Secure API access for external integrations + +### **Laravel Fortify 1.25.4** +- **Purpose**: Authentication scaffolding +- **Features**: Login, registration, password reset + +### **Laravel Socialite 5.18.0** +- **Purpose**: OAuth provider integration +- **Providers**: + - GitHub, GitLab, Google + - Microsoft Azure, Authentik, Discord, Clerk + - Custom OAuth implementations + +## Background Processing + +### **Laravel Horizon 5.30.3** +- **Purpose**: Queue monitoring and management +- **Features**: Real-time queue metrics, failed job handling + +### **Queue System** +- **Backend**: Redis-based queues +- **Jobs**: [app/Jobs/](mdc:app/Jobs/) +- **Processing**: Background deployment and monitoring tasks + +## Development Tools + +### **Build Tools** +- **Vite 6.2.6**: Modern build tool and dev server +- **Laravel Vite Plugin**: Laravel integration +- **PostCSS**: CSS processing pipeline + +### **Code Quality** +- **Laravel Pint**: PHP code style fixer +- **Rector**: PHP automated refactoring +- **PHPStan**: Static analysis tool + +### **Testing Framework** +- **Pest 3.8.0**: Modern PHP testing framework +- **Laravel Dusk**: Browser automation testing +- **PHPUnit**: Unit testing foundation + +## External Integrations + +### **Git Providers** +- **GitHub**: Repository integration and webhooks +- **GitLab**: Self-hosted and cloud GitLab support +- **Bitbucket**: Atlassian integration +- **Gitea**: Self-hosted Git service + +### **Cloud Storage** +- **AWS S3**: [league/flysystem-aws-s3-v3](mdc:composer.json) +- **SFTP**: [league/flysystem-sftp-v3](mdc:composer.json) +- **Local Storage**: File system integration + +### **Notification Services** +- **Email**: [resend/resend-laravel](mdc:composer.json) +- **Discord**: Custom webhook integration +- **Slack**: Webhook notifications +- **Telegram**: Bot API integration +- **Pushover**: Push notifications + +### **Monitoring & Logging** +- **Sentry**: [sentry/sentry-laravel](mdc:composer.json) - Error tracking +- **Laravel Ray**: [spatie/laravel-ray](mdc:composer.json) - Debug tool +- **Activity Log**: [spatie/laravel-activitylog](mdc:composer.json) + +## DevOps & Infrastructure + +### **Docker & Containerization** +- **Docker**: Container runtime +- **Docker Compose**: Multi-container orchestration +- **Docker Swarm**: Container clustering (optional) + +### **Web Servers & Proxies** +- **Nginx**: Primary web server +- **Traefik**: Reverse proxy and load balancer +- **Caddy**: Alternative reverse proxy + +### **Process Management** +- **S6 Overlay**: Process supervisor +- **Supervisor**: Alternative process manager + +### **SSL/TLS** +- **Let's Encrypt**: Automatic SSL certificates +- **Custom Certificates**: Manual SSL management + +## Terminal & Code Editing + +### **XTerm.js 5.5.0** +- **Purpose**: Web-based terminal emulator +- **Features**: SSH session management, real-time command execution +- **Addons**: Fit addon for responsive terminals + +### **Monaco Editor** +- **Purpose**: Code editor component +- **Features**: Syntax highlighting, auto-completion +- **Integration**: Environment variable editing, configuration files + +## API & Documentation + +### **OpenAPI/Swagger** +- **Documentation**: [openapi.json](mdc:openapi.json) (373KB) +- **Generator**: [zircote/swagger-php](mdc:composer.json) +- **API Routes**: [routes/api.php](mdc:routes/api.php) + +### **WebSocket Communication** +- **Laravel Echo**: Real-time event broadcasting +- **Pusher**: WebSocket service integration +- **Soketi**: Self-hosted WebSocket server + +## Package Management + +### **PHP Dependencies** ([composer.json](mdc:composer.json)) +```json +{ + "require": { + "php": "^8.4", + "laravel/framework": "12.4.1", + "livewire/livewire": "^3.5.20", + "spatie/laravel-data": "^4.13.1", + "lorisleiva/laravel-actions": "^2.8.6" + } +} +``` + +### **JavaScript Dependencies** ([package.json](mdc:package.json)) +```json +{ + "devDependencies": { + "vite": "^6.2.6", + "tailwindcss": "^4.1.4", + "@vitejs/plugin-vue": "5.2.3" + }, + "dependencies": { + "@xterm/xterm": "^5.5.0", + "ioredis": "5.6.0" + } +} +``` + +## Configuration Files + +### **Build Configuration** +- **[vite.config.js](mdc:vite.config.js)**: Frontend build setup +- **[postcss.config.cjs](mdc:postcss.config.cjs)**: CSS processing +- **[rector.php](mdc:rector.php)**: PHP refactoring rules +- **[pint.json](mdc:pint.json)**: Code style configuration + +### **Testing Configuration** +- **[phpunit.xml](mdc:phpunit.xml)**: Unit test configuration +- **[phpunit.dusk.xml](mdc:phpunit.dusk.xml)**: Browser test configuration +- **[tests/Pest.php](mdc:tests/Pest.php)**: Pest testing setup + +## Version Requirements + +### **Minimum Requirements** +- **PHP**: 8.4+ +- **Node.js**: 18+ (for build tools) +- **PostgreSQL**: 15+ +- **Redis**: 7+ +- **Docker**: 20.10+ +- **Docker Compose**: 2.0+ + +### **Recommended Versions** +- **Ubuntu**: 22.04 LTS or 24.04 LTS +- **Memory**: 2GB+ RAM +- **Storage**: 20GB+ available space +- **Network**: Stable internet connection for deployments diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc new file mode 100644 index 000000000..8d250b56a --- /dev/null +++ b/.cursor/rules/testing-patterns.mdc @@ -0,0 +1,653 @@ +--- +description: Testing strategies with Pest PHP, Laravel Dusk, and quality assurance patterns +globs: tests/**/*.php, database/factories/*.php +alwaysApply: false +--- +# Coolify Testing Architecture & Patterns + +> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences. + +## Testing Philosophy + +Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions. + +### Test Execution Rules + +**CRITICAL**: Tests are categorized by database dependency: + +#### Unit Tests (`tests/Unit/`) +- **MUST NOT** use database connections +- **MUST** use mocking for models and external dependencies +- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit` +- Purpose: Test isolated logic, helper functions, and business rules + +#### Feature Tests (`tests/Feature/`) +- **MAY** use database connections (factories, migrations, models) +- **MUST** run inside Docker container: `docker exec coolify php artisan test` +- **MUST** use `RefreshDatabase` trait if touching database +- Purpose: Test API endpoints, workflows, and integration scenarios + +**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker. + +### Prefer Mocking Over Database + +When writing tests, always prefer mocking over real database operations: + +```php +// ❌ BAD: Unit test using database +it('extracts custom commands', function () { + $server = Server::factory()->create(['ip' => '1.2.3.4']); + $commands = extract_custom_proxy_commands($server, $yaml); + expect($commands)->toBeArray(); +}); + +// ✅ GOOD: Unit test using mocking +it('extracts custom commands', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn('traefik'); + $commands = extract_custom_proxy_commands($server, $yaml); + expect($commands)->toBeArray(); +}); +``` + +**Design principles for testable code:** +- Use dependency injection instead of global state +- Create interfaces for external dependencies (SSH, Docker, etc.) +- Separate business logic from data persistence +- Make functions accept interfaces instead of concrete models when possible + +## Testing Framework Stack + +### Core Testing Tools +- **Pest PHP 3.8+** - Primary testing framework with expressive syntax +- **Laravel Dusk** - Browser automation and end-to-end testing +- **PHPUnit** - Underlying unit testing framework +- **Mockery** - Mocking and stubbing for isolated tests + +### Testing Configuration +- **[tests/Pest.php](mdc:tests/Pest.php)** - Pest configuration and global setup (1.5KB, 45 lines) +- **[tests/TestCase.php](mdc:tests/TestCase.php)** - Base test case class (163B, 11 lines) +- **[tests/CreatesApplication.php](mdc:tests/CreatesApplication.php)** - Application factory trait (375B, 22 lines) +- **[tests/DuskTestCase.php](mdc:tests/DuskTestCase.php)** - Browser testing setup (1.4KB, 58 lines) + +## Test Directory Structure + +### Test Organization +- **[tests/Feature/](mdc:tests/Feature)** - Feature and integration tests +- **[tests/Unit/](mdc:tests/Unit)** - Unit tests for isolated components +- **[tests/Browser/](mdc:tests/Browser)** - Laravel Dusk browser tests +- **[tests/Traits/](mdc:tests/Traits)** - Shared testing utilities + +## Unit Testing Patterns + +### Model Testing +```php +// Testing Eloquent models +test('application model has correct relationships', function () { + $application = Application::factory()->create(); + + expect($application->server)->toBeInstanceOf(Server::class); + expect($application->environment)->toBeInstanceOf(Environment::class); + expect($application->deployments)->toBeInstanceOf(Collection::class); +}); + +test('application can generate deployment configuration', function () { + $application = Application::factory()->create([ + 'name' => 'test-app', + 'git_repository' => 'https://github.com/user/repo.git' + ]); + + $config = $application->generateDockerCompose(); + + expect($config)->toContain('test-app'); + expect($config)->toContain('image:'); + expect($config)->toContain('networks:'); +}); +``` + +### Service Layer Testing +```php +// Testing service classes +test('configuration generator creates valid docker compose', function () { + $generator = new ConfigurationGenerator(); + $application = Application::factory()->create(); + + $compose = $generator->generateDockerCompose($application); + + expect($compose)->toBeString(); + expect(yaml_parse($compose))->toBeArray(); + expect($compose)->toContain('version: "3.8"'); +}); + +test('docker image parser validates image names', function () { + $parser = new DockerImageParser(); + + expect($parser->isValid('nginx:latest'))->toBeTrue(); + expect($parser->isValid('invalid-image-name'))->toBeFalse(); + expect($parser->parse('nginx:1.21'))->toEqual([ + 'registry' => 'docker.io', + 'namespace' => 'library', + 'repository' => 'nginx', + 'tag' => '1.21' + ]); +}); +``` + +### Action Testing +```php +// Testing Laravel Actions +test('deploy application action creates deployment queue', function () { + $application = Application::factory()->create(); + $action = new DeployApplicationAction(); + + $deployment = $action->handle($application); + + expect($deployment)->toBeInstanceOf(ApplicationDeploymentQueue::class); + expect($deployment->status)->toBe('queued'); + expect($deployment->application_id)->toBe($application->id); +}); + +test('server validation action checks ssh connectivity', function () { + $server = Server::factory()->create([ + 'ip' => '192.168.1.100', + 'port' => 22 + ]); + + $action = new ValidateServerAction(); + + // Mock SSH connection + $this->mock(SshConnection::class, function ($mock) { + $mock->shouldReceive('connect')->andReturn(true); + $mock->shouldReceive('execute')->with('docker --version')->andReturn('Docker version 20.10.0'); + }); + + $result = $action->handle($server); + + expect($result['ssh_connection'])->toBeTrue(); + expect($result['docker_installed'])->toBeTrue(); +}); +``` + +## Feature Testing Patterns + +### API Testing +```php +// Testing API endpoints +test('authenticated user can list applications', function () { + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team); + + $applications = Application::factory(3)->create([ + 'team_id' => $team->id + ]); + + $response = $this->actingAs($user) + ->getJson('/api/v1/applications'); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'name', 'fqdn', 'status', 'created_at'] + ] + ]); +}); + +test('user cannot access applications from other teams', function () { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + + $application = Application::factory()->create([ + 'team_id' => $otherTeam->id + ]); + + $response = $this->actingAs($user) + ->getJson("/api/v1/applications/{$application->id}"); + + $response->assertStatus(403); +}); +``` + +### Deployment Testing +```php +// Testing deployment workflows +test('application deployment creates docker containers', function () { + $application = Application::factory()->create([ + 'git_repository' => 'https://github.com/laravel/laravel.git', + 'git_branch' => 'main' + ]); + + // Mock Docker operations + $this->mock(DockerService::class, function ($mock) { + $mock->shouldReceive('buildImage')->andReturn('app:latest'); + $mock->shouldReceive('createContainer')->andReturn('container_id'); + $mock->shouldReceive('startContainer')->andReturn(true); + }); + + $deployment = $application->deploy(); + + expect($deployment->status)->toBe('queued'); + + // Process the deployment job + $this->artisan('queue:work --once'); + + $deployment->refresh(); + expect($deployment->status)->toBe('success'); +}); + +test('failed deployment triggers rollback', function () { + $application = Application::factory()->create(); + + // Mock failed deployment + $this->mock(DockerService::class, function ($mock) { + $mock->shouldReceive('buildImage')->andThrow(new DeploymentException('Build failed')); + }); + + $deployment = $application->deploy(); + + $this->artisan('queue:work --once'); + + $deployment->refresh(); + expect($deployment->status)->toBe('failed'); + expect($deployment->error_message)->toContain('Build failed'); +}); +``` + +### Webhook Testing +```php +// Testing webhook endpoints +test('github webhook triggers deployment', function () { + $application = Application::factory()->create([ + 'git_repository' => 'https://github.com/user/repo.git', + 'git_branch' => 'main' + ]); + + $payload = [ + 'ref' => 'refs/heads/main', + 'repository' => [ + 'clone_url' => 'https://github.com/user/repo.git' + ], + 'head_commit' => [ + 'id' => 'abc123', + 'message' => 'Update application' + ] + ]; + + $response = $this->postJson("/webhooks/github/{$application->id}", $payload); + + $response->assertStatus(200); + + expect($application->deployments()->count())->toBe(1); + expect($application->deployments()->first()->commit_sha)->toBe('abc123'); +}); + +test('webhook validates payload signature', function () { + $application = Application::factory()->create(); + + $payload = ['invalid' => 'payload']; + + $response = $this->postJson("/webhooks/github/{$application->id}", $payload); + + $response->assertStatus(400); +}); +``` + +## Browser Testing (Laravel Dusk) + +### End-to-End Testing +```php +// Testing complete user workflows +test('user can create and deploy application', function () { + $user = User::factory()->create(); + $server = Server::factory()->create(['team_id' => $user->currentTeam->id]); + + $this->browse(function (Browser $browser) use ($user, $server) { + $browser->loginAs($user) + ->visit('/applications/create') + ->type('name', 'Test Application') + ->type('git_repository', 'https://github.com/laravel/laravel.git') + ->type('git_branch', 'main') + ->select('server_id', $server->id) + ->press('Create Application') + ->assertPathIs('/applications/*') + ->assertSee('Test Application') + ->press('Deploy') + ->waitForText('Deployment started', 10) + ->assertSee('Deployment started'); + }); +}); + +test('user can monitor deployment logs in real-time', function () { + $user = User::factory()->create(); + $application = Application::factory()->create(['team_id' => $user->currentTeam->id]); + + $this->browse(function (Browser $browser) use ($user, $application) { + $browser->loginAs($user) + ->visit("/applications/{$application->id}") + ->press('Deploy') + ->waitForText('Deployment started') + ->click('@logs-tab') + ->waitFor('@deployment-logs') + ->assertSee('Building Docker image') + ->waitForText('Deployment completed', 30); + }); +}); +``` + +### UI Component Testing +```php +// Testing Livewire components +test('server status component updates in real-time', function () { + $user = User::factory()->create(); + $server = Server::factory()->create(['team_id' => $user->currentTeam->id]); + + $this->browse(function (Browser $browser) use ($user, $server) { + $browser->loginAs($user) + ->visit("/servers/{$server->id}") + ->assertSee('Status: Online') + ->waitFor('@server-metrics') + ->assertSee('CPU Usage') + ->assertSee('Memory Usage') + ->assertSee('Disk Usage'); + + // Simulate server going offline + $server->update(['status' => 'offline']); + + $browser->waitForText('Status: Offline', 5) + ->assertSee('Status: Offline'); + }); +}); +``` + +## Database Testing Patterns + +### Migration Testing +```php +// Testing database migrations +test('applications table has correct structure', function () { + expect(Schema::hasTable('applications'))->toBeTrue(); + expect(Schema::hasColumns('applications', [ + 'id', 'name', 'fqdn', 'git_repository', 'git_branch', + 'server_id', 'environment_id', 'created_at', 'updated_at' + ]))->toBeTrue(); +}); + +test('foreign key constraints are properly set', function () { + $application = Application::factory()->create(); + + expect($application->server)->toBeInstanceOf(Server::class); + expect($application->environment)->toBeInstanceOf(Environment::class); + + // Test cascade deletion + $application->server->delete(); + expect(Application::find($application->id))->toBeNull(); +}); +``` + +### Factory Testing +```php +// Testing model factories +test('application factory creates valid models', function () { + $application = Application::factory()->create(); + + expect($application->name)->toBeString(); + expect($application->git_repository)->toStartWith('https://'); + expect($application->server_id)->toBeInt(); + expect($application->environment_id)->toBeInt(); +}); + +test('application factory can create with custom attributes', function () { + $application = Application::factory()->create([ + 'name' => 'Custom App', + 'git_branch' => 'develop' + ]); + + expect($application->name)->toBe('Custom App'); + expect($application->git_branch)->toBe('develop'); +}); +``` + +## Queue Testing + +### Job Testing +```php +// Testing background jobs +test('deploy application job processes successfully', function () { + $application = Application::factory()->create(); + $deployment = ApplicationDeploymentQueue::factory()->create([ + 'application_id' => $application->id, + 'status' => 'queued' + ]); + + $job = new DeployApplicationJob($deployment); + + // Mock external dependencies + $this->mock(DockerService::class, function ($mock) { + $mock->shouldReceive('buildImage')->andReturn('app:latest'); + $mock->shouldReceive('deployContainer')->andReturn(true); + }); + + $job->handle(); + + $deployment->refresh(); + expect($deployment->status)->toBe('success'); +}); + +test('failed job is retried with exponential backoff', function () { + $application = Application::factory()->create(); + $deployment = ApplicationDeploymentQueue::factory()->create([ + 'application_id' => $application->id + ]); + + $job = new DeployApplicationJob($deployment); + + // Mock failure + $this->mock(DockerService::class, function ($mock) { + $mock->shouldReceive('buildImage')->andThrow(new Exception('Network error')); + }); + + expect(fn() => $job->handle())->toThrow(Exception::class); + + // Job should be retried + expect($job->tries)->toBe(3); + expect($job->backoff())->toBe([1, 5, 10]); +}); +``` + +## Security Testing + +### Authentication Testing +```php +// Testing authentication and authorization +test('unauthenticated users cannot access protected routes', function () { + $response = $this->get('/dashboard'); + $response->assertRedirect('/login'); +}); + +test('users can only access their team resources', function () { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $team1 = Team::factory()->create(); + $team2 = Team::factory()->create(); + + $user1->teams()->attach($team1); + $user2->teams()->attach($team2); + + $application = Application::factory()->create(['team_id' => $team1->id]); + + $response = $this->actingAs($user2) + ->get("/applications/{$application->id}"); + + $response->assertStatus(403); +}); +``` + +### Input Validation Testing +```php +// Testing input validation and sanitization +test('application creation validates required fields', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->postJson('/api/v1/applications', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'git_repository', 'server_id']); +}); + +test('malicious input is properly sanitized', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->postJson('/api/v1/applications', [ + 'name' => '', + 'git_repository' => 'javascript:alert("xss")', + 'server_id' => 'invalid' + ]); + + $response->assertStatus(422); +}); +``` + +## Performance Testing + +### Load Testing +```php +// Testing application performance under load +test('application list endpoint handles concurrent requests', function () { + $user = User::factory()->create(); + $applications = Application::factory(100)->create(['team_id' => $user->currentTeam->id]); + + $startTime = microtime(true); + + $response = $this->actingAs($user) + ->getJson('/api/v1/applications'); + + $endTime = microtime(true); + $responseTime = ($endTime - $startTime) * 1000; // Convert to milliseconds + + $response->assertStatus(200); + expect($responseTime)->toBeLessThan(500); // Should respond within 500ms +}); +``` + +### Memory Usage Testing +```php +// Testing memory efficiency +test('deployment process does not exceed memory limits', function () { + $initialMemory = memory_get_usage(); + + $application = Application::factory()->create(); + $deployment = $application->deploy(); + + // Process deployment + $this->artisan('queue:work --once'); + + $finalMemory = memory_get_usage(); + $memoryIncrease = $finalMemory - $initialMemory; + + expect($memoryIncrease)->toBeLessThan(50 * 1024 * 1024); // Less than 50MB +}); +``` + +## Test Utilities and Helpers + +### Custom Assertions +```php +// Custom test assertions +expect()->extend('toBeValidDockerCompose', function () { + $yaml = yaml_parse($this->value); + + return $yaml !== false && + isset($yaml['version']) && + isset($yaml['services']) && + is_array($yaml['services']); +}); + +expect()->extend('toHaveValidSshConnection', function () { + $server = $this->value; + + try { + $connection = new SshConnection($server); + return $connection->test(); + } catch (Exception $e) { + return false; + } +}); +``` + +### Test Traits +```php +// Shared testing functionality +trait CreatesTestServers +{ + protected function createTestServer(array $attributes = []): Server + { + return Server::factory()->create(array_merge([ + 'name' => 'Test Server', + 'ip' => '127.0.0.1', + 'port' => 22, + 'team_id' => $this->user->currentTeam->id + ], $attributes)); + } +} + +trait MocksDockerOperations +{ + protected function mockDockerService(): void + { + $this->mock(DockerService::class, function ($mock) { + $mock->shouldReceive('buildImage')->andReturn('test:latest'); + $mock->shouldReceive('createContainer')->andReturn('container_123'); + $mock->shouldReceive('startContainer')->andReturn(true); + $mock->shouldReceive('stopContainer')->andReturn(true); + }); + } +} +``` + +## Continuous Integration Testing + +### GitHub Actions Integration +```yaml +# .github/workflows/tests.yml +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v3 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + - name: Install dependencies + run: composer install + - name: Run tests + run: ./vendor/bin/pest +``` + +### Test Coverage +```php +// Generate test coverage reports +test('application has adequate test coverage', function () { + $coverage = $this->getCoverageData(); + + expect($coverage['application'])->toBeGreaterThan(80); + expect($coverage['models'])->toBeGreaterThan(90); + expect($coverage['actions'])->toBeGreaterThan(85); +}); +``` diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml new file mode 100644 index 000000000..8836c6632 --- /dev/null +++ b/.github/workflows/chore-pr-comments.yml @@ -0,0 +1,56 @@ +name: Add comment based on label +on: + pull_request_target: + types: + - labeled +jobs: + add-comment: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + actions: none + checks: none + deployments: none + issues: none + packages: none + repository-projects: none + security-events: none + statuses: none + strategy: + matrix: + include: + - label: "⚙️ Service" + body: | + Hi @${{ github.event.pull_request.user.login }}! 👋 + + It appears to us that you are either adding a new service or making changes to an existing one. + We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs. + This will help ensure that our documentation remains accurate and up-to-date for all users. + + Coolify Docs Repository: https://github.com/coollabsio/coolify-docs + How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation + - label: "🛠️ Feature" + body: | + Hi @${{ github.event.pull_request.user.login }}! 👋 + + It appears to us that you are adding a new feature to Coolify. + We kindly ask you to also update the **Coolify Documentation** to include information about this new feature. + This will help ensure that our documentation remains accurate and up-to-date for all users. + + Coolify Docs Repository: https://github.com/coollabsio/coolify-docs + How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation + # - label: "✨ Enhancement" + # body: | + # It appears to us that you are making an enhancement to Coolify. + # We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable. + # 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 + run: gh pr comment "$NUMBER" --body "$BODY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + BODY: ${{ matrix.body }} diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..a2c92df59 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,79 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + if: false + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) + # model: "claude-opus-4-1-20250805" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR + # use_sticky_comment: true + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..9daf0e90e --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,65 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || + (github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) + # model: "claude-opus-4-1-20250805" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test diff --git a/.github/workflows/cleanup-ghcr-untagged.yml b/.github/workflows/cleanup-ghcr-untagged.yml new file mode 100644 index 000000000..1ad41ce16 --- /dev/null +++ b/.github/workflows/cleanup-ghcr-untagged.yml @@ -0,0 +1,24 @@ +name: Cleanup Untagged GHCR Images + +on: + workflow_dispatch: # Allow manual trigger + schedule: + - cron: '0 */6 * * *' # Run every 6 hours to handle large volume (16k+ images) + +env: + GITHUB_REGISTRY: ghcr.io + +jobs: + cleanup-testing-host: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Delete untagged coolify-testing-host images + uses: actions/delete-package-versions@v5 + with: + package-name: 'coolify-testing-host' + package-type: 'container' + min-versions-to-keep: 0 + delete-only-untagged-versions: 'true' diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 09b1e9421..c6aa2dd90 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -28,6 +28,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -50,8 +57,8 @@ jobs: platforms: linux/amd64 push: true tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} aarch64: runs-on: [self-hosted, arm64] @@ -61,6 +68,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -83,8 +97,8 @@ jobs: platforms: linux/aarch64 push: true tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 merge-manifest: runs-on: ubuntu-latest @@ -95,6 +109,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - uses: docker/setup-buildx-action@v3 - name: Login to ${{ env.GITHUB_REGISTRY }} @@ -114,14 +135,14 @@ jobs: - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | docker buildx imagetools create \ - --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ - --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} run: | docker buildx imagetools create \ - --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ - --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - uses: sarisia/actions-status-discord@v1 if: always() diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.phpactor.json b/.phpactor.json new file mode 100644 index 000000000..4d42bbbc5 --- /dev/null +++ b/.phpactor.json @@ -0,0 +1,4 @@ +{ + "$schema": "/phpactor.schema.json", + "language_server_phpstan.enabled": true +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fdf59683..0d28581a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,25 +2,1538 @@ # Changelog All notable changes to this project will be documented in this file. -## [4.0.0-beta.417] - 2025-05-07 +## [unreleased] + +### 🚀 Features + +- Implement TrustHosts middleware to handle FQDN and IP address trust logic +- Implement TrustHosts middleware to handle FQDN and IP address trust logic +- Allow safe environment variable defaults in array-format volumes +- Add signoz template +- *(signoz)* Replace png icon by svg icon +- *(signoz)* Remove explicit 'networks' setting +- *(signoz)* Add predefined environment variables to configure Telemetry, SMTP and email sending for Alert Manager +- *(signoz)* Generate URLs for `otel-collector` service +- *(signoz)* Update documentation link +- *(signoz)* Add healthcheck to otel-collector service +- *(signoz)* Use latest tag instead of hardcoded versions +- *(signoz)* Remove redundant users.xml volume from clickhouse container +- *(signoz)* Replace clickhouse' config.xml volume with simpler configuration +- *(signoz)* Remove deprecated parameters of signoz container +- *(signoz)* Remove volumes from signoz.yaml +- *(signoz)* Assume there is a single zookeeper container +- *(signoz)* Update Clickhouse config to include all settings required by Signoz +- *(signoz)* Update config.xml and users.xml to ensure clickhouse boots correctly +- *(signoz)* Update otel-collector configuration to match upstream +- *(signoz)* Fix otel-collector config for version v0.128.0 +- *(signoz)* Remove unecessary port mapping for otel-collector +- *(signoz)* Add SIGNOZ_JWT_SECRET env var generation +- *(signoz)* Upgrade clickhouse image to 25.5.6 +- *(signoz)* Use latest tag for signoz/zookeeper +- *(signoz)* Update variables for SMTP configuration +- *(signoz)* Replace deprecated `TELEMETRY_ENABLED` by `SIGNOZ_STATSREPORTER_ENABLED` +- *(signoz)* Pin service image tags and `exclude_from_hc` flag to services excluded from health checks +- *(templates)* Add SMTP configuration to ente-photos compose templates +- *(templates)* Add SMTP encryption configuration to ente-photos compose templates + +### 🐛 Bug Fixes + +- Use wasChanged() instead of isDirty() in updated hooks +- Prevent command injection in git ls-remote operations +- Handle null environment variable values in bash escaping +- Critical privilege escalation in team invitation system +- Add authentication context to TeamPolicyTest +- Ensure negative cache results are stored in TrustHosts middleware +- Use wasChanged() instead of isDirty() in updated hook +- Prevent command injection in Docker Compose parsing - add pre-save validation +- Use canonical parser for Windows path validation +- Correct variable name typo in generateGitLsRemoteCommands method +- Update version numbers to 4.0.0-beta.436 and 4.0.0-beta.437 +- Ensure authorization checks are in place for viewing and updating the application +- Ensure authorization check is performed during component mount +- *(signoz)* Remove example secrets to avoid triggering GitGuardian +- *(signoz)* Remove hardcoded container names +- *(signoz)* Remove HTTP collector FQDN in otel-collector +- *(n8n)* Add DB_SQLITE_POOL_SIZE environment variable for configuration + +### 🚜 Refactor + +- Improve validation error handling and coding standards +- Preserve exception chain in validation error handling +- Harden and deduplicate validateShellSafePath +- Replace random ID generation with Cuid2 for unique HTML IDs in form components + +### 🧪 Testing + +- Add coverage for newline and tab rejection in volume strings + +### ⚙️ Miscellaneous Tasks + +- *(signoz)* Remove unused ports +- *(signoz)* Bump version to 0.77.0 +- *(signoz)* Bump version to 0.78.1 + +## [4.0.0-beta.435] - 2025-10-15 + +### 🚀 Features + +- *(docker)* Enhance Docker image handling with new validation and parsing logic +- *(docker)* Improve Docker image submission logic with enhanced parsing +- *(docker)* Refine Docker image processing in application creation +- Add Ente Photos service template +- *(storage)* Add read-only volume handling and UI notifications +- *(service)* Add Elasticsearch password handling in extraFields method +- *(application)* Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications +- *(proxy)* Enhance proxy configuration regeneration by extracting custom commands +- *(backup)* Enhance backup job with S3 upload handling and notifications +- *(storage)* Implement transaction handling in storage settings submission +- *(project)* Enhance project index with resource creation capabilities +- *(dashboard)* Enhance project and server sections with modal input for resource creation +- *(global-search)* Enhance resource creation functionality in search modal +- *(global-search)* Add navigation routes and enhance search functionality +- *(conductor)* Add setup script and configuration file +- *(conductor)* Add run script and update runScriptMode configuration +- *(docker-compose)* Add image specifications for coolify, soketi, and testing-host services +- *(cleanup)* Add force deletion of stuck servers and orphaned SSL certificates +- *(deployment)* Save build-time .env file before build and enhance logging for Dockerfile +- Implement Hetzner deletion failure notification system with email and messaging support +- Enhance proxy status notifications with detailed messages for various states +- Add retry functionality for server validation process +- Add retry mechanism with rate limit handling to API requests in HetznerService +- Implement ValidHostname validation rule and integrate it into server creation process +- Add support for selecting additional SSH keys from Hetzner in server creation form +- Enhance datalist component with unified input container and improved option handling +- Add modal support for creating private keys in server creation form and enhance UI for private key selection +- Add IPv4/IPv6 network configuration for Hetzner server creation +- Add pricing display to Hetzner server creation button +- Add cloud-init script support for Hetzner server creation +- Add cloud-init scripts management UI in Security section +- Add cloud-init scripts to global search +- Add artisan command to clear global search cache +- Add YAML validation for cloud-init scripts +- Add clear button for cloud-init script dropdown +- Add custom webhook notification support +- Add webhook placeholder to Test notification +- Add WebhookChannel placeholder implementation +- Implement actual webhook delivery +- Implement actual webhook delivery with Ray debugging +- Improve webhook URL field UI +- Add UUIDs and URLs to webhook notifications +- *(onboarding)* Redesign user onboarding flow with modern UI/UX +- Replace terminal dropdown with searchable datalist component +- *(onboarding)* Add Hetzner integration and fix navigation issues +- Use new homarr image +- *(templates)* Actually use the new image now +- *(templates)* Pin homarr image version to v1.40.0 +- *(template)* Added newapi +- Add mail environment variables to docmost.yaml +- Add Email Envs, Install more required packages by pdsadmin +- Make an empty pds.env file to trick pdsadmin into working correctly +- Not many know how to setup this without reading pds docs +- Make the other email env also required +- *(templates)* Added Lobe Chat service +- *(service)* Add Gramps Web template +- *(campfire)* Add Docker Compose configuration for Once Campfire service +- Add Hetzner affiliate link to token form +- Update Hetzner affiliate link text and URL +- Add CPU vendor information to server types in Hetzner integration + +### 🐛 Bug Fixes + +- Region env variable +- Ente photos +- *(elasticsearch)* Update Elasticsearch and Kibana configuration for enhanced security and setup +- *(ui)* Make the deployments indicator toast in the bottom-left above the sidebar +- *(environment)* Clear computed property cache after adding environment variables +- *(backup)* Update backup job to use backup_log_uuid for container naming +- *(core)* Set default base_directory and include in submit method +- *(deployment)* Add warning for NIXPACKS_NODE_VERSION in node configurations +- *(deployment)* Save runtime environment variables when skipping build +- *(job)* Correct build logs URL structure in ApplicationPullRequestUpdateJob +- *(tests)* Update Docker command for running feature tests without `-it` flag +- On team creation, redirect to the new team instantly +- *(project)* Update redirect logic after resource creation to include environment UUID +- *(dashboard)* Add cursor pointer to modal input buttons for better UX +- *(modal-confirmation)* Refine escape key handling to ensure modal closes only when open +- *(conductor-setup)* Update script permissions for execution +- *(conductor)* Update run script command to 'spin up' +- *(conductor)* Update run script to include 'spin down' command +- *(docker-compose)* Set pull_policy to 'never' for coolify, soketi, and testing-host services +- *(migration)* Disable transaction for concurrent index creation +- Properly handle transaction for concurrent index operations +- Use correct property declaration for withinTransaction +- *(api-tokens)* Update settings link for API enablement message +- *(css)* Update success color to match design specifications +- *(css)* Update focus styles for input and button utilities to improve accessibility +- *(css)* Remove unnecessary tracking classes from status components for consistency +- *(css)* Update focus styles for Checkbox and modal input components to enhance accessibility +- Refresh server data before showing notification to ensure accurate proxy status +- Update Hetzner server status handling to prevent unnecessary database updates and improve UI responsiveness +- Improve error logging and handling in ServerConnectionCheckJob for Hetzner server status +- Correct dispatch logic for Hetzner server status refresh in checkHetznerServerStatus method +- Streamline proxy status handling in StartProxy and Navbar components +- Improve placeholder text for token name input in cloud provider token form +- Update cloud provider token form with improved placeholder and guidance for API token creation +- *(ci)* Sanitize branch names for Docker tag compatibility +- Set cloud-init script dropdown to empty by default +- Reset cloud-init fields when closing server creation modal +- Improve cloud-init scripts UI styling and behavior +- Allow typing in global search while data loads +- Hide 'No results found' message while data is loading +- Populate webhook notification settings for existing teams +- Register WebhookNotificationSettings with NotificationPolicy +- Add missing server_patch_webhook_notifications field +- Move POST badge before input field +- Use btn-primary for POST badge background +- *(onboarding)* Auto-select first SSH key for better UX +- Prevent container name conflict when updating database port mappings +- Missing 422 error code in openapi spec +- Allow all environment variable fields in API endpoints +- Fixed version +- Fix documentation url +- Bluesky PDS template +- Bluesky PDS template finally works normally +- Add back template info +- Now it automatically generates the JWT secret and the PLC rotation key +- Syntax error on vars +- Remove the SERVICE_EMAIL_ADMIN and make it normal +- Both email envs are needed in order for the PDS to start, so set the other one as required +- Add back template info +- Healthcheck doesn’t need to be 5s +- Make email envs not required +- Domain on coolify +- *(templates)* Update Lobe-chat openai base_url env + required envs +- *(templates)* Lobechat environnement variable +- *(lobe-chat)* Update Docker image tag to a specific version 1.135.5 +- Enable docker network connection for pgadmin service +- *(template/filebrowser)* Correct routing and healthcheck for Filebrowser +- *(template/filebrowser)* Correct healthcheck for Filebrowser +- *(campfire)* Update port configuration from 80 to 3000 in Docker Compose file +- *(campfire)* Correct port comment from 3000 to 80 in Docker Compose file +- *(campfire)* Update service definition to use image instead of build in Docker Compose file +- *(templates)* Remove mattermost healthcheck command according to lack of shell in new version +- Prevent duplicate services on image change and enable real-time UI refresh +- Enhance run script to remove existing containers before starting +- Prevent TypeError in database General components with null server +- Add authorization checks to database Livewire components +- Add missing save_runtime_environment_variables() in deploy_simple_dockerfile +- *(git)* Handle Git redirects and improve URL parsing for tangled.sh and other Git hosts +- Improve logging and add shell escaping for git ls-remote +- Update run script to use bun for development +- Restore original run script functionality in conductor.json +- Use computed imageTag variable for digest-based Docker images +- Improve Docker image digest handling and add auto-parse feature +- 'new image' quick action not progressing to resource selection + +### 💼 Other + +- Ente config +- Cofig variables +- Lean Config +- Env +- Services & Env variables +- Product hunt Ente Logo +- Remove volumes +- Add ray logging for Hetzner createServer API request/response +- Escape all shell directory paths in Git deployment commands + +### 🚜 Refactor + +- *(environment-variables)* Adjust ordering logic for environment variables +- Update ente photos configuration for improved service management +- *(deployment)* Streamline environment variable generation in ApplicationDeploymentJob +- *(deployment)* Enhance deployment data retrieval and relationships +- *(deployment)* Standardize environment variable handling in ApplicationDeploymentJob +- *(deployment)* Update environment variable handling for Docker builds +- *(navbar, app)* Improve layout and styling for better responsiveness +- *(switch-team)* Remove label from team selection component for cleaner UI +- *(global-search, environment)* Streamline environment retrieval with new query method +- *(backup)* Make backup_log_uuid initialization lazy +- *(checkbox, utilities, global-search)* Enhance focus styles for better accessibility +- *(forms)* Simplify wire:dirty class bindings for input, select, and textarea components +- Replace direct SslCertificate queries with server relationship methods for consistency +- *(ui)* Improve cloud-init script save checkbox visibility and styling +- Enable cloud-init save checkbox at all times with backend validation +- Improve cloud-init script UX and remove description field +- Improve cloud-init script management UI and cache control +- Remove debug sleep from global search modal +- Reduce cloud-init label width for better layout +- Remove SendsWebhook interface +- Reposition POST badge as button +- Migrate database components from legacy model binding to explicit properties +- Volumes set back to ./pds-data:/pds +- *(campfire)* Streamline environment variable definitions in Docker Compose file ### 📚 Documentation - Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- *(tests)* Update testing guidelines for unit and feature tests +- *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references +- *(database-patterns)* Add critical note on mass assignment protection for new columns +- Clarify cloud-init script compatibility +- Update changelog +- Update changelog + +### 🎨 Styling + +- *(campfire)* Format environment variables for better readability in Docker Compose file +- *(campfire)* Update comment for DISABLE_SSL environment variable for clarity + +### 🧪 Testing + +- Improve Git ls-remote parsing tests with uppercase SHA and negative cases + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update Coolify version numbers to 4.0.0-beta.435 and 4.0.0-beta.436 +- Update package-lock.json +- *(service)* Update convex template and image + +## [4.0.0-beta.434] - 2025-10-03 + +### 🚀 Features + +- *(deployments)* Enhance Docker build argument handling for multiline variables +- *(deployments)* Add log copying functionality to clipboard in dev +- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services + +### 🐛 Bug Fixes + +- *(deployments)* Enhance builder container management and environment variable handling + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update version numbers for Coolify releases +- *(versions)* Bump Coolify stable version to 4.0.0-beta.434 + +## [4.0.0-beta.433] - 2025-10-01 + +### 🚀 Features + +- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling +- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources +- *(global-search)* Integrate projects and environments into global search functionality +- *(storage)* Consolidate storage management into a single component with enhanced UI +- *(deployments)* Add support for Coolify variables in Dockerfile + +### 🐛 Bug Fixes + +- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow +- *(ui)* Update docker registry image helper text for clarity +- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options +- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow +- *(api)* Correct OpenAPI schema annotations for array items +- *(ui)* Improve queued deployment status readability in dark mode +- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic +- *(git)* Enhance error handling for missing branch information during deployment +- *(git)* Trim whitespace from repository, branch, and commit SHA fields +- *(deployments)* Order deployments by ID for consistent retrieval + +### 💼 Other + +- *(storage)* Enhance file storage management with new properties and UI improvements +- *(core)* Update projects property type and enhance UI styling +- *(components)* Adjust SVG icon sizes for consistency across applications and services +- *(components)* Auto-focus first input in modal on open +- *(styles)* Enhance focus styles for buttons and links +- *(components)* Enhance close button accessibility in modal + +### 🚜 Refactor + +- *(global-search)* Change event listener to window level for global search modal +- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management +- *(dashboard)* Replace project navigation method with direct link in UI +- *(global-search)* Improve event handling and cleanup in global search component + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files + +## [4.0.0-beta.432] - 2025-09-29 + +### 🚀 Features + +- *(application)* Implement order-based pattern matching for watch paths with negation support +- *(github)* Enhance Docker Compose input fields for better user experience +- *(dev-seeders)* Add PersonalAccessTokenSeeder to create development API tokens +- *(application)* Add conditional .env file creation for Symfony apps during PHP deployment +- *(application)* Enhance watch path parsing to support negation syntax +- *(application)* Add normalizeWatchPaths method to improve watch path handling +- *(validation)* Enhance ValidGitRepositoryUrl to support additional safe characters and add comprehensive unit tests for various Git repository URL formats +- *(deployment)* Implement detection for Laravel/Symfony frameworks and configure NIXPACKS PHP environment variables accordingly + +### 🐛 Bug Fixes + +- *(application)* Restrict GitHub-based application settings to non-public repositories +- *(traits)* Update saved_outputs handling in ExecuteRemoteCommand to use collection methods for better performance +- *(application)* Enhance domain handling by replacing both dots and dashes with underscores for HTML form binding +- *(constants)* Reduce command timeout from 7200 to 3600 seconds for improved performance +- *(github)* Update repository URL to point to the v4.x branch for development +- *(models)* Update sorting of scheduled database backups to order by creation date instead of name +- *(socialite)* Add custom base URL support for GitLab provider in OAuth settings +- *(configuration-checker)* Update message to clarify redeployment requirement for configuration changes +- *(application)* Reduce docker stop timeout from 30 to 10 seconds for improved application shutdown efficiency +- *(application)* Increase docker stop timeout from 10 to 30 seconds for better application shutdown handling +- *(validation)* Update git:// URL validation to support port numbers and tilde characters in paths +- Resolve scroll lock issue after closing quick search modal with escape key +- Prevent quick search modal duplication from keyboard shortcuts + +### 🚜 Refactor + +- *(tests)* Simplify matchWatchPaths tests and update implementation for better clarity +- *(deployment)* Improve environment variable handling in ApplicationDeploymentJob +- *(deployment)* Remove commented-out code and streamline environment variable handling in ApplicationDeploymentJob +- *(application)* Improve handling of docker compose domains by normalizing keys and ensuring valid JSON structure +- *(forms)* Update wire:model bindings to use 'blur' instead of 'blur-sm' for input fields across multiple views + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(application)* Remove debugging statement from loadComposeFile method +- *(workflows)* Update Claude GitHub Action configuration to support new event types and improve permissions + +## [4.0.0-beta.431] - 2025-09-24 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.430] - 2025-09-24 + +### 🚀 Features + +- *(add-watch-paths-for-services)* Show watch paths field for docker compose applications + +### 🐛 Bug Fixes + +- *(PreviewCompose)* Adds port to preview urls +- *(deployment-job)* Enhance build time variable analysis +- *(docker)* Adjust openssh-client installation in Dockerfile to avoid version bug +- *(docker)* Streamline openssh-client installation in Dockerfile +- *(team)* Normalize email case in invite link generation +- *(README)* Update Juxtdigital description to reflect current services +- *(environment-variable-warning)* Enhance warning logic to check for problematic variable values +- *(install)* Ensure proper quoting of environment file paths to prevent issues with spaces +- *(security)* Implement authorization checks for terminal access management +- *(ui)* Improve mobile sidebar close behavior + +### 🚜 Refactor + +- *(installer)* Improve install script +- *(upgrade)* Improve upgrade script +- *(installer, upgrade)* Enhance environment variable management +- *(upgrade)* Enhance logging and quoting in upgrade scripts +- *(upgrade)* Replace warning div with a callout component for better UI consistency +- *(ui)* Replace warning and error divs with callout components for improved consistency and readability +- *(ui)* Improve styling and consistency in environment variable warning and docker cleanup components +- *(security)* Streamline update check functionality and improve UI button interactions in patches view + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Increment coolify version numbers to 4.0.0-beta.431 and 4.0.0-beta.432 in configuration files +- *(versions)* Update coolify version numbers to 4.0.0-beta.432 and 4.0.0-beta.433 in configuration files +- Remove unused files +- Adjust wording +- *(workflow)* Update pull request trigger to pull_request_target and refine permissions for enhanced security + +## [4.0.0-beta.429] - 2025-09-23 + +### 🚀 Features + +- *(environment)* Replace is_buildtime_only with is_runtime and is_buildtime flags for environment variables, updating related logic and views +- *(deployment)* Handle buildtime and runtime variables during deployment +- *(search)* Implement global search functionality with caching and modal interface +- *(search)* Enable query logging for global search caching +- *(environment)* Add dynamic checkbox options for environment variable settings based on user permissions and variable types +- *(redaction)* Implement sensitive information redaction in logs and commands +- Improve detection of special network modes +- *(api)* Add endpoint to update backup configuration by UUID and backup ID; modify response to include backup id +- *(databases)* Enhance backup management API with new endpoints and improved data handling +- *(github)* Add GitHub app management endpoints +- *(github)* Add update and delete endpoints for GitHub apps +- *(databases)* Enhance backup update and deletion logic with validation +- *(environment-variables)* Implement environment variable analysis for build-time issues +- *(databases)* Implement unique UUID generation for backup execution +- *(cloud-check)* Enhance subscription reporting in CloudCheckSubscription command +- *(cloud-check)* Enhance CloudCheckSubscription command with fix options +- *(stripe)* Enhance subscription handling and verification process +- *(private-key-refresh)* Add refresh dispatch on private key update and connection check +- *(comments)* Add automated comments for labeled pull requests to guide documentation updates +- *(comments)* Ping PR author + +### 🐛 Bug Fixes + +- *(docker)* Enhance container status aggregation to include restarting and exited states +- *(environment)* Correct grammatical errors in helper text for environment variable sorting checkbox +- *(ui)* Change order and fix ui on small screens +- Order for git deploy types +- *(deployment)* Enhance Dockerfile modification for build-time variables and secrets during deployment in case of docker compose buildpack +- Hide sensitive email change fields in team member responses +- *(domains)* Trim whitespace from domains before validation +- *(databases)* Update backup retrieval logic to include team context +- *(environment-variables)* Update affected services in environment variable analysis +- *(team)* Clear stripe_subscription_id on subscription end +- *(github)* Update authentication method for GitHub app operations +- *(databases)* Restrict database updates to allowed fields only +- *(cache)* Add Model import to ClearsGlobalSearchCache trait for improved functionality +- *(environment-variables)* Correct method call syntax in analyzeBuildVariable function +- *(clears-global-search-cache)* Refine team retrieval logic in getTeamIdForCache method +- *(subscription-job)* Enhance retry logic for VerifyStripeSubscriptionStatusJob +- *(environment-variable)* Update checkbox visibility and helper text for build and runtime options +- *(deployment-job)* Escape single quotes in build arguments for Docker Compose command + +### 🚜 Refactor + +- *(environment)* Conditionally render Docker Build Secrets checkbox based on build pack type +- *(search)* Optimize cache clearing logic to only trigger on searchable field changes +- *(environment)* Streamline rendering of Docker Build Secrets checkbox and adjust layout for environment variable settings +- *(proxy)* Streamline proxy configuration form layout and improve button placements +- *(remoteProcess)* Remove redundant file transfer functions for improved clarity +- *(github)* Enhance API request handling and validation +- *(databases)* Remove deprecated backup parameters from API documentation +- *(databases)* Streamline backup queries to use team context +- *(databases)* Update backup queries to use team-specific method +- *(server)* Update dispatch messages and streamline data synchronization +- *(cache)* Update team retrieval method in ClearsGlobalSearchCache trait +- *(database-backup)* Move unique UUID generation for backup execution to database loop +- *(cloud-commands)* Consolidate and enhance subscription management commands +- *(toast-component)* Improve layout and icon handling in toast notifications +- *(private-key-update)* Implement transaction for private key association and connection validation + +### 📚 Documentation + +- Update changelog +- Update changelog +- *(claude)* Update testing guidelines and add note on Application::team relationship + +### 🎨 Styling + +- *(environment-variable)* Adjust SVG icon margin for improved layout in locked state +- *(proxy)* Adjust padding in proxy configuration form for better visual alignment + +### ⚙️ Miscellaneous Tasks + +- Change order of runtime and buildtime +- *(docker-compose)* Update soketi image version to 1.0.10 in production and Windows configurations +- *(versions)* Update coolify version numbers to 4.0.0-beta.430 and 4.0.0-beta.431 in configuration files + +## [4.0.0-beta.428] - 2025-09-15 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.427] - 2025-09-15 + +### 🚀 Features + +- Add Ente Photos service template +- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic +- *(ui)* Display current version in settings dropdown and update UI accordingly +- *(settings)* Add option to restrict PR deployments to repository members and contributors +- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling +- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring +- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting +- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo +- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process +- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching +- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management +- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob +- *(application)* Display parsing version in development mode and clean up domain conflict modal markup +- *(deployment)* Add SERVICE_NAME variables for service discovery +- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display +- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options +- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook +- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios +- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration +- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation +- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option +- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods +- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development +- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval +- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins +- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience +- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members +- *(deployment)* Implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution +- *(deployment)* Introduce 'use_build_secrets' setting for enhanced security during Docker builds and update related logic in deployment process + +### 🐛 Bug Fixes + +- *(ui)* Transactional email settings link on members page (#6491) +- *(api)* Add custom labels generation for applications with readonly container label setting enabled +- *(ui)* Add cursor pointer to upgrade button for better user interaction +- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE +- *(command)* Enhance database deletion command to support multiple database types +- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records +- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues +- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal +- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling +- Appwrite template - 500 errors, missing env vars etc. +- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method +- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling +- *(web-routes)* Enhance backup response messages to clarify local and S3 availability +- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management +- *(private-key)* Implement transaction handling and error verification for private key storage operations +- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration +- *(application)* Add functionality to stop and remove Docker containers on server +- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment +- *(security)* Update contact email for reporting vulnerabilities to enhance privacy +- *(feedback)* Update feedback email address to improve communication with users +- *(security)* Update contact email for vulnerability reports to improve security communication +- *(navbar)* Restrict subscription link visibility to admin users in cloud environment +- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration +- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers +- *(server)* Update server usability check to reflect actual Docker availability status +- *(server)* Add build server check to disable Sentinel and update related logic +- *(server)* Implement refreshServer method and update navbar event listener for improved server state management +- *(deployment)* Prevent removal of running containers for pull request deployments in case of failure +- *(docker)* Redirect stderr to stdout for container log retrieval to capture error messages +- *(clone)* Update destinations method call to ensure correct retrieval of selected destination + +### 🚜 Refactor + +- *(jobs)* Pull github changelogs from cdn instead of github +- *(command)* Streamline database deletion process to handle multiple database types and improve user experience +- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience +- *(command)* Remove InitChangelog command as it is no longer needed +- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations +- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews +- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process +- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation +- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging +- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation +- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting +- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency +- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code +- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code +- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency +- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code +- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling +- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks +- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code +- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call +- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling +- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers +- *(application-source)* Improve layout and accessibility of Git repository links in the application source view +- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency +- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables +- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability +- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability +- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy +- *(clone)* Enhance application cloning by separating production and preview environment variable handling +- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests +- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys +- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management +- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration +- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration +- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance +- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications +- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables +- *(remoteProcess)* Remove command log comments for file transfers to simplify code +- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code +- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency +- *(server)* Remove debugging ray call from validateConnection method for cleaner code +- *(deployment)* Conditionally cleanup build secrets based on Docker BuildKit support and remove redundant calls for improved efficiency +- *(deployment)* Remove redundant environment variable documentation from Dockerfile comments to streamline the deployment process +- *(deployment)* Streamline Docker BuildKit detection and environment variable handling for enhanced security during application deployment +- *(deployment)* Optimize BuildKit capabilities detection and remove unnecessary comments for cleaner deployment logic +- *(deployment)* Rename method for modifying Dockerfile to improve clarity and streamline build secrets integration + +### 📚 Documentation + +- Update changelog +- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428 +- Use main value then fallback to service_ values +- Remove webhooks table cleanup +- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase +- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files +- *(constants)* Update realtime_version from 1.0.10 to 1.0.11 +- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10 +- *(docker)* Add a blank line for improved readability in Dockerfile +- *(versions)* Bump coolify version to 4.0.0-beta.429 and nightly version to 4.0.0-beta.430 + +## [4.0.0-beta.426] - 2025-08-28 + +### 🚜 Refactor + +- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.426 and nightly version to 4.0.0-beta.427 + +## [4.0.0-beta.425] - 2025-08-28 + +### 🚀 Features + +- *(domains)* Implement domain conflict detection and user confirmation modal across application components +- *(domains)* Add force_domain_override option and enhance domain conflict detection responses + +### 🐛 Bug Fixes + +- *(previews)* Simplify FQDN generation logic by removing unnecessary empty check +- *(templates)* Update Matrix service compose configuration for improved compatibility and clarity + +### 🚜 Refactor + +- *(urls)* Replace generateFqdn with generateUrl for consistent URL generation across applications +- *(domains)* Rename check_domain_usage to checkDomainUsage and update references across the application +- *(auth)* Simplify access control logic in CanAccessTerminal and ServerPolicy by allowing all users to perform actions + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.425 and nightly version to 4.0.0-beta.426 + +## [4.0.0-beta.424] - 2025-08-27 + +### 💼 Other + +- Allow deploy from container image hash + +### 📚 Documentation + +- Update changelog +- Update changelog + +## [4.0.0-beta.423] - 2025-08-27 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.422] - 2025-08-27 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.421] - 2025-08-26 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.9] - 2025-08-26 + +### 🚀 Features + +- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing ) + +### 🐛 Bug Fixes + +- *(backups)* S3 backup upload is failing +- *(backups)* Rollback helper update for now +- *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications +- *(parsers)* Implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage. +- *(git)* Submodule update command uses an unsupported option (#6454) +- *(service)* Swap URL for FQDN on matrix template (#6466) +- *(parsers)* Enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation. +- *(docker)* Update parser version in FQDN generation for service-specific URLs +- *(parsers)* Do not modify service names, only for getting fqdns and related envs +- *(compose)* Temporary allow to edit volumes in apps (compose based) and services + +### 🚜 Refactor + +- *(git)* Improve submodule cloning +- *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version +- *(core)* Update version +- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422 +- Update version +- Update development node version +- Update coolify version to 4.0.0-beta.423 and nightly version to 4.0.0-beta.424 +- Update coolify version to 4.0.0-beta.424 and nightly version to 4.0.0-beta.425 + +## [4.0.0-beta.420.8] - 2025-08-26 + +### 🚜 Refactor + +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.7] - 2025-08-26 + +### 🚀 Features + +- *(service)* Add TriliumNext service (#5970) +- *(service)* Add Matrix service (#6029) +- *(service)* Add GitHub Action runner service (#6209) +- *(terminal)* Dispatch focus event for terminal after connection and enhance focus handling in JavaScript +- *(lang)* Add Polish language & improve forgot_password translation (#6306) +- *(service)* Update Authentik template (#6264) +- *(service)* Add sequin template (#6105) +- *(service)* Add pi-hole template (#6020) +- *(services)* Add Chroma service (#6201) +- *(service)* Add OpenPanel template (#5310) +- *(service)* Add librechat template (#5654) +- *(service)* Add Homebox service (#6116) +- *(service)* Add pterodactyl & wings services (#5537) +- *(service)* Add Bluesky PDS template (#6302) +- *(input)* Add autofocus attribute to input component for improved accessibility +- *(core)* Finally fqdn is fqdn and url is url. haha +- *(user)* Add changelog read tracking and unread count method +- *(templates)* Add new service templates and update existing compose files for various applications +- *(changelog)* Implement automated changelog fetching from GitHub and enhance changelog read tracking +- *(drizzle-gateway)* Add new drizzle-gateway service with configuration and logo +- *(drizzle-gateway)* Enhance service configuration by adding Master Password field and updating compose file path +- *(templates)* Add new service templates for Homebox, LibreChat, Pterodactyl, and Wings with corresponding configurations and logos +- *(templates)* Add Bluesky PDS service template and update compose file with new environment variable +- *(readme)* Add CubePath as a big sponsor and include new small sponsors with logos +- *(api)* Add create_environment endpoint to ProjectController for environment creation in projects +- *(api)* Add endpoints for managing environments in projects, including listing, creating, and deleting environments +- *(backup)* Add disable local backup option and related logic for S3 uploads +- *(dev patches)* Add functionality to send test email with patch data in development mode +- *(templates)* Added category per service +- *(email)* Implement email change request and verification process +- Generate category for services +- *(service)* Add elasticsearch template (#6300) +- *(sanitization)* Integrate DOMPurify for HTML sanitization across components +- *(cleanup)* Add command for sanitizing name fields across models +- *(sanitization)* Enhance HTML sanitization with improved DOMPurify configuration +- *(validation)* Centralize validation patterns for names and descriptions +- *(git-settings)* Add support for shallow cloning in application settings +- *(auth)* Implement authorization checks for server updates across multiple components +- *(auth)* Implement authorization for PrivateKey management +- *(auth)* Implement authorization for Docker and server management +- *(validation)* Add custom validation rules for Git repository URLs and branches +- *(security)* Add authorization checks for package updates in Livewire components +- *(auth)* Implement authorization checks for application management +- *(auth)* Enhance API error handling for authorization exceptions +- *(auth)* Add comprehensive authorization checks for all kind of resource creations +- *(auth)* Implement authorization checks for database management +- *(auth)* Refine authorization checks for S3 storage and service management +- *(auth)* Implement comprehensive authorization checks across API controllers +- *(auth)* Introduce resource creation authorization middleware and policies for enhanced access control +- *(auth)* Add middleware for resource creation authorization +- *(auth)* Enhance authorization checks in Livewire components for resource management +- *(validation)* Add ValidIpOrCidr rule for validating IP addresses and CIDR notations; update API access settings UI and add comprehensive tests +- *(docs)* Update architecture and development guidelines; enhance form components with built-in authorization system and improve routing documentation +- *(docs)* Expand authorization documentation for custom Alpine.js components; include manual protection patterns and implementation guidelines +- *(sentinel)* Implement SentinelRestarted event and update Livewire components to handle server restart notifications +- *(api)* Enhance IP access control in middleware and settings; support CIDR notation and special case for 0.0.0.0 to allow all IPs +- *(acl)* Change views/backend code to able to use proper ACL's later on. Currently it is not enabled. +- *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management +- *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans +- *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching +- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration + +### 🐛 Bug Fixes + +- *(service)* Triliumnext platform and link +- *(application)* Update service environment variables when generating domain for Docker Compose +- *(application)* Add option to suppress toast notifications when loading compose file +- *(git)* Tracking issue due to case sensitivity +- *(git)* Tracking issue due to case sensitivity +- *(git)* Tracking issue due to case sensitivity +- *(ui)* Delete button width on small screens (#6308) +- *(service)* Matrix entrypoint +- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307) +- *(docker)* Volumes get delete when stopping a service if `Delete Unused Volumes` is activated (#6317) +- *(docker)* Cleanup always running on deletion +- *(proxy)* Remove hardcoded port 80/443 checks (#6275) +- *(service)* Update healthcheck of penpot backend container (#6272) +- *(api)* Duplicated logs in application endpoint (#6292) +- *(service)* Documenso signees always pending (#6334) +- *(api)* Update service upsert to retain name and description values if not set +- *(database)* Custom postgres configs with SSL (#6352) +- *(policy)* Update delete method to check for admin status in S3StoragePolicy +- *(container)* Sort containers alphabetically by name in ExecuteContainerCommand and update filtering in Terminal Index +- *(application)* Streamline environment variable updates for Docker Compose services and enhance FQDN generation logic +- *(constants)* Update 'Change Log' to 'Changelog' in settings dropdown +- *(constants)* Update coolify version to 4.0.0-beta.420.7 +- *(parsers)* Clarify comments and update variable checks for FQDN and URL handling +- *(terminal)* Update text color for terminal availability message and improve readability +- *(drizzle-gateway)* Remove healthcheck from drizzle-gateway compose file and update service template +- *(templates)* Should generate old SERVICE_FQDN service templates as well +- *(constants)* Update official service template URL to point to the v4.x branch for accuracy +- *(git)* Use exact refspec in ls-remote to avoid matching similarly named branches (e.g., changeset-release/main). Use refs/heads/ or provider-specific PR refs. +- *(ApplicationPreview)* Change null check to empty check for fqdn in generate_preview_fqdn method +- *(email notifications)* Enhance EmailChannel to validate team membership for recipients and handle errors gracefully +- *(service api)* Separate create and update service functionalities +- *(templates)* Added a category tag for the docs service filter +- *(application)* Clear Docker Compose specific data when switching away from dockercompose +- *(database)* Conditionally set started_at only if the database is running +- *(ui)* Handle null values in postgres metrics (#6388) +- Disable env sorting by default +- *(proxy)* Filter host network from default proxy (#6383) +- *(modal)* Enhance confirmation text handling +- *(notification)* Update unread count display and improve HTML rendering +- *(select)* Remove unnecessary sanitization for logo rendering +- *(tags)* Update tag display to limit name length and adjust styling +- *(init)* Improve error handling for deployment and template pulling processes +- *(settings-dropdown)* Adjust unread count badge size and display logic for better consistency +- *(sanitization)* Enhance DOMPurify hook to remove Alpine.js directives for improved XSS protection +- *(servercheck)* Properly check server statuses with and without Sentinel +- *(errors)* Update error pages to provide navigation options +- *(github-deploy-key)* Update background color for selected private keys in deployment key selection UI +- *(auth)* Enhance authorization checks in application management + +### 💼 Other + +- *(settings-dropdown)* Add icons to buttons for improved UI in settings dropdown +- *(ui)* Introduce task for simplifying resource operations UI by replacing boxes with dropdown selections to enhance user experience and streamline interactions + +### 🚜 Refactor + +- *(jobs)* Remove logging for ScheduledJobManager and ServerResourceManager start and completion +- *(services)* Update validation rules to be optional +- *(service)* Improve langfuse +- *(service)* Improve openpanel template +- *(service)* Improve librechat +- *(public-git-repository)* Enhance form structure and add autofocus to repository URL input +- *(public-git-repository)* Remove commented-out code for cleaner template +- *(templates)* Update service template file handling to use dynamic file name from constants +- *(parsers)* Streamline domain handling in applicationParser and improve DNS validation logic +- *(templates)* Replace SERVICE_FQDN variables with SERVICE_URL in compose files for consistency +- *(links)* Replace inline SVGs with reusable external link component for consistency and improved maintainability +- *(previews)* Improve layout and add deployment/application logs links for previews +- *(docker compose)* Remove deprecated newParser function and associated test file to streamline codebase +- *(shared helpers)* Remove unused parseServiceVolumes function to clean up codebase +- *(parsers)* Update volume parsing logic to use beforeLast and afterLast for improved accuracy +- *(validation)* Implement centralized validation patterns across components +- *(jobs)* Rename job classes to indicate deprecation status +- Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility + +### 📚 Documentation + +- *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development +- Add AGENTS.md for project guidance and development instructions +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Improve matrix service +- *(service)* Format runner service +- *(service)* Improve sequin +- *(service)* Add `NOT_SECURED` env to Postiz (#6243) +- *(service)* Improve evolution-api environment variables (#6283) +- *(service)* Update Langfuse template to v3 (#6301) +- *(core)* Remove unused argument +- *(deletion)* Rename isDeleteOperation to deleteConnectedNetworks +- *(docker)* Remove unused arguments on StopService +- *(service)* Homebox formatting +- Clarify usage of custom redis configuration (#6321) +- *(changelogs)* Add .gitignore for changelogs directory and remove outdated changelog files for May, June, and July 2025 +- *(service)* Change affine images (#6366) +- Elasticsearch URL, fromatting and add category +- Update service-templates json files +- *(docs)* Remove AGENTS.md file; enhance CLAUDE.md with detailed form authorization patterns and service configuration examples +- *(cleanup)* Remove unused GitLab view files for change, new, and show pages +- *(workflows)* Add backlog directory to build triggers for production and staging workflows +- *(config)* Disable auto_commit in backlog configuration to prevent automatic commits +- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php +- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations + +### ◀️ Revert + +- *(parser)* Enhance FQDN generation logic for services and applications + +## [4.0.0-beta.420.6] - 2025-07-18 + +### 🚀 Features + +- *(service)* Enable password protection for the Wireguard Ul +- *(queues)* Improve Horizon config to reduce CPU and RAM usage (#6212) +- *(service)* Add Gowa service (#6164) +- *(container)* Add updatedSelectedContainer method to connect to non-default containers and update wire:model for improved reactivity +- *(application)* Implement environment variable updates for Docker Compose applications, including creation, updating, and deletion of SERVICE_FQDN and SERVICE_URL variables + +### 🐛 Bug Fixes + +- *(installer)* Public IPv4 link does not work +- *(composer)* Version constraint of prompts +- *(service)* Budibase secret keys (#6205) +- *(service)* Wg-easy host should be just the FQDN +- *(ui)* Search box overlaps the sidebar navigation (#6176) +- *(webhooks)* Exclude webhook routes from CSRF protection (#6200) +- *(services)* Update environment variable naming convention to use underscores instead of dashes for SERVICE_FQDN and SERVICE_URL + +### 🚜 Refactor + +- *(service)* Improve gowa +- *(previews)* Streamline preview domain generation logic in ApplicationDeploymentJob for improved clarity and maintainability +- *(services)* Simplify environment variable updates by using updateOrCreate and add cleanup for removed FQDNs + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Update Nitropage template (#6181) +- *(versions)* Update all version +- *(bump)* Update composer deps +- *(version)* Bump Coolify version to 4.0.0-beta.420.6 + +## [4.0.0-beta.420.4] - 2025-07-08 + +### 🚀 Features + +- *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs +- *(scheduling)* Add frequency filter option for manual execution of scheduled jobs +- *(logging)* Implement scheduled logs command and enhance backup/task scheduling with cron checks +- *(logging)* Add frequency filters for scheduled logs command to support hourly, daily, weekly, and monthly job views +- *(scheduling)* Introduce ScheduledJobManager and ServerResourceManager for enhanced job scheduling and resource management +- *(previews)* Implement soft delete and cleanup for ApplicationPreview, enhancing resource management in DeleteResourceJob + +### 🐛 Bug Fixes + +- *(service)* Update Postiz compose configuration for improved server availability +- *(install.sh)* Use IPV4_PUBLIC_IP variable in output instead of repeated curl +- *(env)* Generate literal env variables better +- *(deployment)* Update x-data initialization in deployment view for improved functionality +- *(deployment)* Enhance COOLIFY_URL and COOLIFY_FQDN variable generation for better compatibility +- *(deployment)* Improve docker-compose domain handling and environment variable generation +- *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library +- *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy +- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management +- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6 +- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy +- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7 +- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency +- *(horizon)* Update queue configuration to use environment variable for dynamic queue management +- *(horizon)* Add silenced jobs +- *(application)* Sanitize service names for HTML form binding and ensure original names are stored in docker compose domains +- *(previews)* Adjust padding for rate limit message in application previews +- *(previews)* Order application previews by pull request ID in descending order +- *(previews)* Add unique wire keys for preview containers and services based on pull request ID +- *(previews)* Enhance domain generation logic for application previews, ensuring unique domains are created when none are set +- *(previews)* Refine preview domain generation for Docker Compose applications, ensuring correct method usage based on build pack type +- *(ui)* Typo on proxy request handler tooltip (#6192) +- *(backups)* Large database backups are not working (#6217) +- *(backups)* Error message if there is no exception + +### 🚜 Refactor + +- *(previews)* Streamline preview URL generation by utilizing application method +- *(application)* Adjust layout and spacing in general application view for improved UI +- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency +- *(scheduling)* Replace deprecated job checks with ScheduledJobManager and ServerResourceManager for improved scheduling efficiency +- *(previews)* Move preview domain generation logic to ApplicationPreview model for better encapsulation and consistency across webhook handlers + +### 📚 Documentation + +- Update changelog +- Update changelog + +## [4.0.0-beta.420.3] - 2025-07-03 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.2] - 2025-07-03 + +### 🚀 Features + +- *(template)* Added excalidraw (#6095) +- *(template)* Add excalidraw service configuration with documentation and tags + +### 🐛 Bug Fixes + +- *(terminal)* Ensure shell execution only uses valid shell if available in terminal command +- *(ui)* Improve destination selection description for clarity in resource segregation +- *(jobs)* Update middleware to use expireAfter for WithoutOverlapping in multiple job classes +- Removing eager loading (#6071) +- *(template)* Adjust health check interval and retries for excalidraw service +- *(ui)* Env variable settings wrong order +- *(service)* Ensure configuration changes are properly tracked and dispatched + +### 🚜 Refactor + +- *(ui)* Enhance project cloning interface with improved table layout for server and resource selection +- *(terminal)* Simplify command construction for SSH execution +- *(settings)* Streamline instance admin checks and initialization of settings in Livewire components +- *(policy)* Optimize team membership checks in S3StoragePolicy +- *(popup)* Improve styling and structure of the small popup component +- *(shared)* Enhance FQDN generation logic for services in newParser function +- *(redis)* Enhance CleanupRedis command with dry-run option and improved key deletion logic +- *(init)* Standardize method naming conventions and improve command structure in Init.php +- *(shared)* Improve error handling in getTopLevelNetworks function to return network name on invalid docker-compose.yml +- *(database)* Improve error handling for unsupported database types in StartDatabaseProxy + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively +- *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively + +## [4.0.0-beta.420.1] - 2025-06-26 + +### 🐛 Bug Fixes + +- *(server)* Prepend 'mux_' to UUID in muxFilename method for consistent naming +- *(ui)* Enhance terminal access messaging to clarify server functionality and terminal status +- *(database)* Proxy ssl port if ssl is enabled + +### 🚜 Refactor + +- *(ui)* Separate views for instance settings to separate paths to make it cleaner +- *(ui)* Remove unnecessary step3ButtonText attributes from modal confirmation components for cleaner code + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files + +## [4.0.0-beta.420] - 2025-06-26 + +### 🚀 Features + +- *(service)* Add Miniflux service (#5843) +- *(service)* Add Pingvin Share service (#5969) +- *(auth)* Add Discord OAuth Provider (#5552) +- *(auth)* Add Clerk OAuth Provider (#5553) +- *(auth)* Add Zitadel OAuth Provider (#5490) +- *(core)* Set custom API rate limit (#5984) +- *(service)* Enhance service status handling and UI updates +- *(cleanup)* Add functionality to delete teams with no members or servers in CleanupStuckedResources command +- *(ui)* Add heart icon and enhance popup messaging for sponsorship support +- *(settings)* Add sponsorship popup toggle and corresponding database migration +- *(migrations)* Add optimized indexes to activity_log for improved query performance + +### 🐛 Bug Fixes + +- *(service)* Audiobookshelf healthcheck command (#5993) +- *(service)* Downgrade Evolution API phone version (#5977) +- *(service)* Pingvinshare-with-clamav +- *(ssh)* Scp requires square brackets for ipv6 (#6001) +- *(github)* Changing github app breaks the webhook. it does not anymore +- *(parser)* Improve FQDN generation and update environment variable handling +- *(ui)* Enhance status refresh buttons with loading indicators +- *(ui)* Update confirmation button text for stopping database and service +- *(routes)* Update middleware for deploy route to use 'api.ability:deploy' +- *(ui)* Refine API token creation form and update helper text for clarity +- *(ui)* Adjust layout of deployments section for improved alignment +- *(ui)* Adjust project grid layout and refine server border styling for better visibility +- *(ui)* Update border styling for consistency across components and enhance loading indicators +- *(ui)* Add padding to section headers in settings views for improved spacing +- *(ui)* Reduce gap between input fields in email settings for better alignment +- *(docker)* Conditionally enable gzip compression in Traefik labels based on configuration +- *(parser)* Enable gzip compression conditionally for Pocketbase images and streamline service creation logic +- *(ui)* Update padding for trademarks policy and enhance spacing in advanced settings section +- *(ui)* Correct closing tag for sponsorship link in layout popups +- *(ui)* Refine wording in sponsorship donation prompt in layout popups +- *(ui)* Update navbar icon color and enhance popup layout for sponsorship support +- *(ui)* Add target="_blank" to sponsorship links in layout popups for improved user experience +- *(models)* Refine comment wording in User model for clarity on user deletion criteria +- *(models)* Improve user deletion logic in User model to handle team member roles and prevent deletion if user is alone in root team +- *(ui)* Update wording in sponsorship prompt for clarity and engagement +- *(shared)* Refactor gzip handling for Pocketbase in newParser function for improved clarity + +### 🚜 Refactor + +- *(service)* Update Hoarder to their new name karakeep (#5964) +- *(service)* Karakeep naming and formatting +- *(service)* Improve miniflux +- *(core)* Rename API rate limit ENV +- *(ui)* Simplify container selection form in execute-container-command view +- *(email)* Streamline SMTP and resend settings logic for improved clarity +- *(invitation)* Rename methods for consistency and enhance invitation deletion logic +- *(user)* Streamline user deletion process and enhance team management logic + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Update Evolution API image to the official one (#6031) +- *(versions)* Bump coolify versions to v4.0.0-beta.420 and v4.0.0-beta.421 +- *(dependencies)* Update composer dependencies to latest versions including resend-laravel to ^0.19.0 and aws-sdk-php to 3.347.0 +- *(versions)* Update Coolify version to 4.0.0-beta.420.1 and add new services (karakeep, miniflux, pingvinshare) to service templates + +## [4.0.0-beta.419] - 2025-06-17 + +### 🚀 Features + +- *(core)* Add 'postmarketos' to supported OS list +- *(service)* Add memos service template (#5032) +- *(ui)* Upgrade to Tailwind v4 (#5710) +- *(service)* Add Navidrome service template (#5022) +- *(service)* Add Passbolt service (#5769) +- *(service)* Add Vert service (#5663) +- *(service)* Add Ryot service (#5232) +- *(service)* Add Marimo service (#5559) +- *(service)* Add Diun service (#5113) +- *(service)* Add Observium service (#5613) +- *(service)* Add Leantime service (#5792) +- *(service)* Add Limesurvey service (#5751) +- *(service)* Add Paymenter service (#5809) +- *(service)* Add CodiMD service (#4867) +- *(modal)* Add dispatchAction property to confirmation modal +- *(security)* Implement server patching functionality +- *(service)* Add Typesense service (#5643) +- *(service)* Add Yamtrack service (#5845) +- *(service)* Add PG Back Web service (#5079) +- *(service)* Update Maybe service and adjust it for the new release (#5795) +- *(oauth)* Set redirect uri as optional and add default value (#5760) +- *(service)* Add apache superset service (#4891) +- *(service)* Add One Time Secret service (#5650) +- *(service)* Add Seafile service (#5817) +- *(service)* Add Netbird-Client service (#5873) +- *(service)* Add OrangeHRM and Grist services (#5212) +- *(rules)* Add comprehensive documentation for Coolify architecture and development practices for AI tools, especially for cursor +- *(server)* Implement server patch check notifications +- *(api)* Add latest query param to Service restart API (#5881) +- *(api)* Add connect_to_docker_network setting to App creation API (#5691) +- *(routes)* Restrict backup download access to team admins and owners +- *(destination)* Update confirmation modal text and add persistent storage warning for server deployment +- *(terminal-access)* Implement terminal access control for servers and containers, including UI updates and backend logic +- *(ca-certificate)* Add CA certificate management functionality with UI integration and routing +- *(security-patches)* Add update check initialization and enhance notification messaging in UI +- *(previews)* Add force deploy without cache functionality and update deploy method to accept force rebuild parameter +- *(security-patterns)* Expand sensitive patterns list to include additional security-related variables +- *(database-backup)* Add MongoDB credential extraction and backup handling to DatabaseBackupJob +- *(activity-monitor)* Implement auto-scrolling functionality and dynamic content observation for improved user experience +- *(utf8-handling)* Implement UTF-8 sanitization for command outputs and enhance error handling in logs processing +- *(navbar)* Add Traefik dashboard availability check and server IP handling; refactor dynamic configurations loading +- *(proxy-dashboard)* Implement ProxyDashboardCacheService to manage Traefik dashboard cache; clear cache on configuration changes and proxy actions +- *(terminal-connection)* Enhance terminal connection handling with auto-connect feature and improved status messaging +- *(terminal)* Implement resize handling with ResizeObserver for improved terminal responsiveness +- *(migration)* Add is_sentinel_enabled column to server_settings with default true +- *(seeder)* Dispatch StartProxy action for each server in ProductionSeeder +- *(seeder)* Add CheckAndStartSentinelJob dispatch for each server in ProductionSeeder +- *(seeder)* Conditionally dispatch StartProxy action based on proxy check result +- *(service)* Update Changedetection template (#5937) + +### 🐛 Bug Fixes + +- *(constants)* Adding 'fedora-asahi-remix' as a supported OS (#5646) +- *(authentik)* Update docker-compose configuration for authentik service +- *(api)* Allow nullable destination_uuid (#5683) +- *(service)* Fix documenso startup and mail (#5737) +- *(docker)* Fix production dockerfile +- *(service)* Navidrome service +- *(service)* Passbolt +- *(service)* Add missing ENVs to NTFY service (#5629) +- *(service)* NTFY is behind a proxy +- *(service)* Vert logo and ENVs +- *(service)* Add platform to Observium service +- *(ActivityMonitor)* Prevent multiple event dispatches during polling +- *(service)* Convex ENVs and update image versions (#5827) +- *(service)* Paymenter +- *(ApplicationDeploymentJob)* Ensure correct COOLIFY_FQDN/COOLIFY_URL values (#4719) +- *(service)* Snapdrop no matching manifest error (#5849) +- *(service)* Use the same volume between chatwoot and sidekiq (#5851) +- *(api)* Validate docker_compose_raw input in ApplicationsController +- *(api)* Enhance validation for docker_compose_raw in ApplicationsController +- *(select)* Update PostgreSQL versions and titles in resource selection +- *(database)* Include DatabaseStatusChanged event in activityMonitor dispatch +- *(css)* Tailwind v5 things +- *(service)* Diun ENV for consistency +- *(service)* Memos service name +- *(css)* 8+ issue with new tailwind v4 +- *(css)* `bg-coollabs-gradient` not working anymore +- *(ui)* Add back missing service navbar components +- *(deploy)* Update resource timestamp handling in deploy_resource method +- *(patches)* DNF reboot logic is flipped +- *(deployment)* Correct syntax for else statement in docker compose build command +- *(shared)* Remove unused relation from queryDatabaseByUuidWithinTeam function +- *(deployment)* Correct COOLIFY_URL and COOLIFY_FQDN assignments based on parsing version in preview deployments +- *(docker)* Ensure correct parsing of environment variables by limiting explode to 2 parts +- *(project)* Update selected environment handling to use environment name instead of UUID +- *(ui)* Update server status display and improve server addition layout +- *(service)* Neon WS Proxy service not working on ARM64 (#5887) +- *(server)* Enhance error handling in server patch check notifications +- *(PushServerUpdateJob)* Add null checks before updating application and database statuses +- *(environment-variables)* Update label text for build variable checkboxes to improve clarity +- *(service-management)* Update service stop and restart messages for improved clarity and formatting +- *(preview-form)* Update helper text formatting in preview URL template input for better readability +- *(application-management)* Improve stop messages for application, database, and service to enhance clarity and formatting +- *(application-configuration)* Prevent access to preview deployments for deploy_key applications and update menu visibility accordingly +- *(select-component)* Handle exceptions during parameter retrieval and environment selection in the mount method +- *(previews)* Escape container names in stopContainers method to prevent shell injection vulnerabilities +- *(docker)* Add protection against empty container queries in GetContainersStatus to prevent unnecessary updates +- *(modal-confirmation)* Decode HTML entities in confirmation text to ensure proper display +- *(select-component)* Enhance user interaction by adding cursor styles and disabling selection during processing +- *(deployment-show)* Remove unnecessary fixed positioning for button container to improve layout responsiveness +- *(email-notifications)* Change notify method to notifyNow for immediate test email delivery +- *(service-templates)* Update Convex service configuration to use FQDN variables +- *(database-heading)* Simplify stop database message for clarity +- *(navbar)* Remove unnecessary x-init directive for loading proxy configuration +- *(patches)* Add padding to loading message for better visibility during update checks +- *(terminal-connection)* Improve error handling and stability for auto-connection; enhance component readiness checks and retry logic +- *(terminal)* Add unique wire:key to terminal component for improved reactivity and state management +- *(css)* Adjust utility classes in utilities.css for consistent application of Tailwind directives +- *(css)* Refine utility classes in utilities.css for proper Tailwind directive application +- *(install)* Update Docker installation script to use dynamic OS_TYPE and correct installation URL +- *(cloudflare)* Add error handling to automated Cloudflare configuration script +- *(navbar)* Add error handling for proxy status check to improve user feedback +- *(web)* Update user team retrieval method for consistent authentication handling +- *(cloudflare)* Update refresh method to correctly set Cloudflare tunnel status and improve user notification on IP address update +- *(service)* Update service template for affine and add migration service for improved deployment process +- *(supabase)* Update Supabase service images and healthcheck methods for improved reliability +- *(terminal)* Now it should work +- *(degraded-status)* Remove unnecessary whitespace in badge element for cleaner HTML +- *(routes)* Add name to security route for improved route management +- *(migration)* Update default value handling for is_sentinel_enabled column in server_settings +- *(seeder)* Conditionally dispatch CheckAndStartSentinelJob based on server's sentinel status +- *(service)* Disable healthcheck logging for Gotenberg (#6005) +- *(service)* Joplin volume name (#5930) +- *(server)* Update sentinelUpdatedAt assignment to use server's sentinel_updated_at property + +### 💼 Other + +- Add support for postmarketOS (#5608) +- *(core)* Simplify events for app/db/service status changes + +### 🚜 Refactor + +- *(service)* Observium +- *(service)* Improve leantime +- *(service)* Imporve limesurvey +- *(service)* Improve CodiMD +- *(service)* Typsense +- *(services)* Improve yamtrack +- *(service)* Improve paymenter +- *(service)* Consolidate configuration change dispatch logic and remove unused navbar component +- *(sidebar)* Simplify server patching link by removing button element +- *(slide-over)* Streamline button element and improve code readability +- *(service)* Enhance modal confirmation component with event dispatching for service stop actions +- *(slide-over)* Enhance class merging for improved component styling +- *(core)* Use property promotion +- *(service)* Improve maybe +- *(applications)* Remove unused docker compose raw decoding +- *(service)* Make TYPESENSE_API_KEY required +- *(ui)* Show toast when server does not work and on stop +- *(service)* Improve superset +- *(service)* Improve Onetimesecret +- *(service)* Improve Seafile +- *(service)* Improve orangehrm +- *(service)* Improve grist +- *(application)* Enhance application stopping logic to support multiple servers +- *(pricing-plans)* Improve label class binding for payment frequency selection +- *(error-handling)* Replace generic Exception with RuntimeException for improved error specificity +- *(error-handling)* Change Exception to RuntimeException for clearer error reporting +- *(service)* Remove informational dispatch during service stop for cleaner execution +- *(server-ui)* Improve layout and messaging in advanced settings and charts views +- *(terminal-access)* Streamline resource retrieval and enhance terminal access messaging in UI +- *(terminal)* Enhance terminal connection management and error handling, including improved reconnection logic and cleanup procedures +- *(application-deployment)* Separate handling of FAILED and CANCELLED_BY_USER statuses for clearer logic and notification +- *(jobs)* Update middleware to include job-specific identifiers for WithoutOverlapping +- *(jobs)* Modify middleware to use job-specific identifier for WithoutOverlapping +- *(environment-variables)* Remove debug logging from bulk submit handling for cleaner code +- *(environment-variables)* Simplify application build pack check in environment variable handling +- *(logs)* Adjust padding in logs view for improved layout consistency +- *(application-deployment)* Streamline post-deployment process by always dispatching container status check +- *(service-management)* Enhance container stopping logic by implementing parallel processing and removing deprecated methods +- *(activity-monitor)* Change activity property visibility and update view references for consistency +- *(activity-monitor)* Enhance layout responsiveness by adjusting class bindings and structure for better display +- *(service-management)* Update stopContainersInParallel method to enforce Server type hint for improved type safety +- *(service-management)* Rearrange docker cleanup logic in StopService to improve readability +- *(database-management)* Simplify docker cleanup logic in StopDatabase to enhance readability +- *(activity-monitor)* Consolidate activity monitoring logic and remove deprecated NewActivityMonitor component +- *(activity-monitor)* Update dispatch method to use activityMonitor instead of deprecated newActivityMonitor +- *(push-server-update)* Enhance application preview handling by incorporating pull request IDs and adding status update protections +- *(docker-compose)* Replace hardcoded Docker Compose configuration with external YAML template for improved database detection testing +- *(test-database-detection)* Rename services for clarity, add new database configurations, and update application service dependencies +- *(database-detection)* Enhance isDatabaseImage function to utilize service configuration for improved detection accuracy +- *(install-scripts)* Update Docker installation process to include manual installation fallback and improve error handling +- *(logs-view)* Update logs display for service containers with improved headings and dynamic key binding +- *(logs)* Enhance container loading logic and improve UI for logs display across various resource types +- *(cloudflare-tunnel)* Enhance layout and structure of Cloudflare Tunnel documentation and confirmation modal +- *(terminal-connection)* Streamline auto-connection logic and improve component readiness checks +- *(logs)* Remove unused methods and debug functionality from Logs.php for cleaner code +- *(remoteProcess)* Update sanitize_utf8_text function to accept nullable string parameter for improved type safety +- *(events)* Remove ProxyStarted event and associated ProxyStartedNotification listener for code cleanup +- *(navbar)* Remove unnecessary parameters from server navbar component for cleaner implementation +- *(proxy)* Remove commented-out listener and method for cleaner code structure +- *(events)* Update ProxyStatusChangedUI constructor to accept nullable teamId for improved flexibility +- *(cloudflare)* Update server retrieval method for improved query efficiency +- *(navbar)* Remove unused PHP use statement for cleaner code +- *(proxy)* Streamline proxy status handling and improve dashboard availability checks +- *(navbar)* Simplify proxy status handling and enhance loading indicators for better user experience +- *(resource-operations)* Filter out build servers from the server list and clean up commented-out code in the resource operations view +- *(execute-container-command)* Simplify connection logic and improve terminal availability checks +- *(navigation)* Remove wire:navigate directive from configuration links for cleaner HTML structure +- *(proxy)* Update StartProxy calls to use named parameter for async option +- *(clone-project)* Enhance server retrieval by including destinations and filtering out build servers +- *(ui)* Terminal +- *(ui)* Remove terminal header from execute-container-command view +- *(ui)* Remove unnecessary padding from deployment, backup, and logs sections + +### 📚 Documentation + +- Update changelog +- *(service)* Add new docs link for zipline (#5912) +- Update changelog +- Update changelog +- Update changelog + +### 🎨 Styling + +- *(css)* Update padding utility for password input and add newline in app.css +- *(css)* Refine badge utility styles in utilities.css +- *(css)* Enhance badge utility styles in utilities.css + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.419 and nightly version to 4.0.0-beta.420 in configuration files +- *(service)* Rename hoarder server to karakeep (#5607) +- *(service)* Update Supabase services (#5708) +- *(service)* Remove unused documenso env +- *(service)* Formatting and cleanup of ryot +- *(docs)* Remove changelog and add it to gitignore +- *(versions)* Update version to 4.0.0-beta.419 +- *(service)* Diun formatting +- *(docs)* Update CHANGELOG.md +- *(service)* Switch convex vars +- *(service)* Pgbackweb formatting and naming update +- *(service)* Remove typesense default API key +- *(service)* Format yamtrack healthcheck +- *(core)* Remove unused function +- *(ui)* Remove unused stopEvent code +- *(service)* Remove unused env +- *(tests)* Update test environment database name and add new feature test for converting container environment variables to array +- *(service)* Update Immich service (#5886) +- *(service)* Remove unused logo +- *(api)* Update API docs +- *(dependencies)* Update package versions in composer.json and composer.lock for improved compatibility and performance +- *(dependencies)* Update package versions in package.json and package-lock.json for improved stability and features +- *(version)* Update coolify-realtime to version 1.0.9 in docker-compose and versions files +- *(version)* Update coolify version to 4.0.0-beta.420 and nightly version to 4.0.0-beta.421 +- *(service)* Changedetection remove unused code + +## [4.0.0-beta.417] - 2025-05-07 + +### 🐛 Bug Fixes + +- *(select)* Update fallback logo path to use absolute URL for improved reliability + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.418 ## [4.0.0-beta.416] - 2025-05-05 +### 🚀 Features + +- *(migration)* Add 'is_migrated' and 'custom_type' columns to service_applications and service_databases tables +- *(backup)* Implement custom database type selection and enhance scheduled backups management +- *(README)* Add Gozunga and Macarne to sponsors list +- *(redis)* Add scheduled cleanup command for Redis keys and enhance cleanup logic + +### 🐛 Bug Fixes + +- *(service)* Graceful shutdown of old container (#5731) +- *(ServerCheck)* Enhance proxy container check to ensure it is running before proceeding +- *(applications)* Include pull_request_id in deployment queue check to prevent duplicate deployments +- *(database)* Update label for image input field to improve clarity +- *(ServerCheck)* Set default proxy status to 'exited' to handle missing container state +- *(database)* Reduce container stop timeout from 300 to 30 seconds for improved responsiveness +- *(ui)* System theming for charts (#5740) +- *(dev)* Mount points?! +- *(dev)* Proxy mount point +- *(ui)* Allow adding scheduled backups for non-migrated databases +- *(DatabaseBackupJob)* Escape PostgreSQL password in backup command (#5759) +- *(ui)* Correct closing div tag in service index view + +### 🚜 Refactor + +- *(Database)* Streamline container shutdown process and reduce timeout duration +- *(core)* Streamline container stopping process and reduce timeout duration; update related methods for consistency +- *(database)* Update DB facade usage for consistency across service files +- *(database)* Enhance application conversion logic and add existence checks for databases and applications +- *(actions)* Standardize method naming for network and configuration deletion across application and service classes +- *(logdrain)* Consolidate log drain stopping logic to reduce redundancy +- *(StandaloneMariadb)* Add type hint for destination method to improve code clarity +- *(DeleteResourceJob)* Streamline resource deletion logic and improve conditional checks for database types +- *(jobs)* Update middleware to prevent job release after expiration for CleanupInstanceStuffsJob, RestartProxyJob, and ServerCheckJob +- *(jobs)* Unify middleware configuration to prevent job release after expiration for DockerCleanupJob and PushServerUpdateJob + ### 📚 Documentation - Update changelog - Update changelog +### ⚙️ Miscellaneous Tasks + +- *(seeder)* Update git branch from 'main' to 'v4.x' for multiple examples in ApplicationSeeder +- *(versions)* Update coolify version to 4.0.0-beta.417 and nightly version to 4.0.0-beta.418 + ## [4.0.0-beta.415] - 2025-04-29 +### 🐛 Bug Fixes + +- *(ui)* Remove required attribute from image input in service application view +- *(ui)* Change application image validation to be nullable in service application view +- *(Server)* Correct proxy path formatting for Traefik proxy type + ### 📚 Documentation - Update changelog +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.416 and nightly version to 4.0.0-beta.417 in configuration files; fix links in deployment view + +## [4.0.0-beta.414] - 2025-04-28 + +### 🐛 Bug Fixes + +- *(ui)* Disable livewire navigate feature (causing spam of setInterval()) + ## [4.0.0-beta.413] - 2025-04-28 ### 💼 Other @@ -36,12 +1549,6 @@ ### ⚙️ Miscellaneous Tasks - *(workflows)* Adjust workflow for announcement -## [4.0.0-beta.412] - 2025-04-23 - -### ⚙️ Miscellaneous Tasks - -- *(versions)* Update coolify version to 4.0.0-beta.412 and nightly version to 4.0.0-beta.413 in configuration files - ## [4.0.0-beta.411] - 2025-04-23 ### 🚀 Features @@ -50,6 +1557,7 @@ ### 🚀 Features - *(api)* Enhance OpenAPI specifications with token variable and additional key attributes - *(docker)* Add HTTP Basic Authentication support and enhance hostname parsing in Docker run conversion - *(api)* Add HTTP Basic Authentication fields to OpenAPI specifications and enhance PrivateKey model descriptions +- *(README)* Add InterviewPal sponsorship link and corresponding SVG icon ### 🐛 Bug Fixes @@ -60,6 +1568,8 @@ ### 🐛 Bug Fixes - Add 201 json code to servers validate api response - *(docker)* Ensure password hashing only occurs when HTTP Basic Authentication is enabled - *(docker)* Enhance hostname and GPU option validation in Docker run to compose conversion +- *(terminal)* Enhance WebSocket client verification with authorized IPs in terminal server +- *(ApplicationDeploymentJob)* Ensure source is an object before checking GitHub app properties ### 🚜 Refactor @@ -83,6 +1593,12 @@ ### 📚 Documentation ### ⚙️ Miscellaneous Tasks - *(versions)* Update coolify version to 4.0.0-beta.411 and nightly version to 4.0.0-beta.412 in configuration files +- *(versions)* Update coolify version to 4.0.0-beta.412 and nightly version to 4.0.0-beta.413 in configuration files +- *(versions)* Update coolify version to 4.0.0-beta.413 and nightly version to 4.0.0-beta.414 in configuration files +- *(versions)* Update realtime version to 1.0.8 in versions.json +- *(versions)* Update realtime version to 1.0.8 in versions.json +- *(docker)* Update soketi image version to 1.0.8 in production configuration files +- *(versions)* Update coolify version to 4.0.0-beta.414 and nightly version to 4.0.0-beta.415 in configuration files ## [4.0.0-beta.410] - 2025-04-18 @@ -7407,4 +8923,6 @@ ### 🐛 Bug Fixes - Secrets join - ENV variables set differently +## [1.0.0] - 2021-03-24 + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..34149d28a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,708 @@ +# CLAUDE.md + +This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository. + +> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows. +> +> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/. + +## Project Overview + +Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization. + +## Development Commands + +### Frontend Development +- `npm run dev` - Start Vite development server for frontend assets +- `npm run build` - Build frontend assets for production + +### Backend Development +Only run artisan commands inside "coolify" container when in development. +- `php artisan serve` - Start Laravel development server +- `php artisan migrate` - Run database migrations +- `php artisan queue:work` - Start queue worker for background jobs +- `php artisan horizon` - Start Laravel Horizon for queue monitoring +- `php artisan tinker` - Start interactive PHP REPL + +### Code Quality +- `./vendor/bin/pint` - Run Laravel Pint for code formatting +- `./vendor/bin/phpstan` - Run PHPStan for static analysis +- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database) + +### Running Tests +**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container: +- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database) +- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies) +- Unit tests should use mocking and avoid database connections +- Feature tests that require database must be run in the `coolify` container + +## Architecture Overview + +### Technology Stack +- **Backend**: Laravel 12 (PHP 8.4) +- **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+ +- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues) +- **Real-time**: Soketi (WebSocket server) +- **Containerization**: Docker & Docker Compose +- **Queue Management**: Laravel Horizon + +### Key Components + +#### Core Models +- `Application` - Deployed applications with Git integration (74KB, highly complex) +- `Server` - Remote servers managed by Coolify (46KB, complex) +- `Service` - Docker Compose services (58KB, complex) +- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.) +- `Team` - Multi-tenancy support +- `Project` - Grouping of environments and resources +- `Environment` - Environment isolation (staging, production, etc.) + +#### Job System +- Uses Laravel Horizon for queue management +- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob` +- `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling + +#### Deployment Flow +1. Git webhook triggers deployment +2. `ApplicationDeploymentJob` handles build and deployment +3. Docker containers are managed on target servers +4. Proxy configuration (Nginx/Traefik) is updated + +#### Server Management +- SSH-based server communication via `ExecuteRemoteCommand` trait +- Docker installation and management +- Proxy configuration generation +- Resource monitoring and cleanup + +### Directory Structure +- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.) +- `app/Jobs/` - Background queue jobs +- `app/Livewire/` - Frontend components (full-stack with Livewire) +- `app/Models/` - Eloquent models +- `app/Rules/` - Custom validation rules +- `app/Http/Middleware/` - HTTP middleware +- `bootstrap/helpers/` - Helper functions for various domains +- `database/migrations/` - Database schema evolution +- `routes/` - Application routing (web.php, api.php, webhooks.php, channels.php) +- `resources/views/livewire/` - Livewire component views +- `tests/` - Pest tests (Feature and Unit) + +## Development Guidelines + +### Frontend Philosophy +Coolify uses a **server-side first** approach with minimal JavaScript: +- **Livewire** for server-side rendering with reactive components +- **Alpine.js** for lightweight client-side interactions +- **Tailwind CSS** for utility-first styling with dark mode support +- **Enhanced Form Components** with built-in authorization system +- Real-time updates via WebSocket without page refreshes + +### Form Authorization Pattern +**IMPORTANT**: When creating or editing forms, ALWAYS include authorization: + +#### For Form Components (Input, Select, Textarea, Checkbox, Button): +Use `canGate` and `canResource` attributes for automatic authorization: +```html + +... + +Save +``` + +#### For Modal Components: +Wrap with `@can` directives: +```html +@can('update', $resource) + ... + ... +@endcan +``` + +#### In Livewire Components: +Always add the `AuthorizesRequests` trait and check permissions: +```php +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + +class MyComponent extends Component +{ + use AuthorizesRequests; + + public function mount() + { + $this->authorize('view', $this->resource); + } + + public function update() + { + $this->authorize('update', $this->resource); + // ... update logic + } +} +``` + +### Livewire Component Structure +- Components located in `app/Livewire/` +- Views in `resources/views/livewire/` +- State management handled on the server +- Use wire:model for two-way data binding +- Dispatch events for component communication + +### Code Organization Patterns +- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`) +- **Livewire Components**: Handle UI and user interactions +- **Jobs**: Handle asynchronous operations +- **Traits**: Provide shared functionality (e.g., `ExecuteRemoteCommand`) +- **Helper Functions**: Domain-specific helpers in `bootstrap/helpers/` + +### Database Patterns +- Use Eloquent ORM for database interactions +- Implement relationships properly (HasMany, BelongsTo, etc.) +- Use database transactions for critical operations +- Leverage query scopes for reusable queries +- Apply indexes for performance-critical queries +- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment + +### Security Best Practices +- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum +- **Authorization**: Team-based access control with policies and enhanced form components +- **Form Component Security**: Built-in `canGate` authorization system for UI components +- **API Security**: Token-based auth with IP allowlisting +- **Secrets Management**: Never log or expose sensitive data +- **Input Validation**: Always validate user input with Form Requests or Rules +- **SQL Injection Prevention**: Use Eloquent ORM or parameterized queries + +### API Development +- RESTful endpoints in `routes/api.php` +- Use API Resources for response formatting +- Implement rate limiting for public endpoints +- Version APIs when making breaking changes +- Document endpoints with clear examples + +### Testing Strategy +- **Framework**: Pest for expressive testing +- **Structure**: Feature tests for user flows, Unit tests for isolated logic +- **Coverage**: Test critical paths and edge cases +- **Mocking**: Use Laravel's built-in mocking for external services +- **Database**: Use RefreshDatabase trait for test isolation + +#### Test Execution Environment +**CRITICAL**: Database-dependent tests MUST run inside Docker container: +- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit` +- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test` +- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/` +- Always mock external services and SSH connections in tests + +#### Test Design Philosophy +**PREFER MOCKING**: When designing features and writing tests: +- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces) +- **Mock by default**: Unit tests should mock models and external dependencies using Mockery +- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test +- **Only use database when necessary**: Feature tests should test integration points, not isolated logic +- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests + +### Routing Conventions +- Group routes by middleware and prefix +- Use route model binding for cleaner controllers +- Name routes consistently (resource.action) +- Implement proper HTTP verbs (GET, POST, PUT, DELETE) + +### Error Handling +- Use `handleError()` helper for consistent error handling +- Log errors with appropriate context +- Return user-friendly error messages +- Implement proper HTTP status codes + +### Performance Considerations +- Use eager loading to prevent N+1 queries +- Implement caching for frequently accessed data +- Queue heavy operations +- Optimize database queries with proper indexes +- Use chunking for large data operations + +### Code Style +- Follow PSR-12 coding standards +- Use Laravel Pint for automatic formatting +- Write descriptive variable and method names +- Keep methods small and focused +- Document complex logic with clear comments + +## Cloud Instance Considerations + +We have a cloud instance of Coolify (hosted version) with: +- 2 Horizon worker servers +- Thousands of connected servers +- Thousands of active users +- High-availability requirements + +When developing features: +- Consider scalability implications +- Test with large datasets +- Implement efficient queries +- Use queues for heavy operations +- Consider rate limiting and resource constraints +- Implement proper error recovery mechanisms + +## Important Reminders + +- Always run code formatting: `./vendor/bin/pint` +- Test your changes: `./vendor/bin/pest` +- Check for static analysis issues: `./vendor/bin/phpstan` +- Use existing patterns and helpers +- Follow the established directory structure +- Maintain backward compatibility +- Document breaking changes +- Consider performance impact on large-scale deployments + +## Additional Documentation + +This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants): + +> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below. + +### Architecture & Patterns +- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure +- [Deployment Architecture](.cursor/rules/deployment-architecture.mdc) - Deployment patterns and flows +- [Database Patterns](.cursor/rules/database-patterns.mdc) - Database design and query patterns +- [Frontend Patterns](.cursor/rules/frontend-patterns.mdc) - Livewire and Alpine.js patterns +- [API & Routing](.cursor/rules/api-and-routing.mdc) - API design and routing conventions + +### Development & Security +- [Development Workflow](.cursor/rules/development-workflow.mdc) - Development best practices +- [Security Patterns](.cursor/rules/security-patterns.mdc) - Security implementation details +- [Form Components](.cursor/rules/form-components.mdc) - Enhanced form components with authorization +- [Testing Patterns](.cursor/rules/testing-patterns.mdc) - Testing strategies and examples + +### Project Information +- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure +- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information +- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.7 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/socialite (SOCIALITE) - v5 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 +- laravel/pint (PINT) - v1 +- laravel/telescope (TELESCOPE) - v5 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- rector/rector (RECTOR) - v2 +- laravel-echo (ECHO) - v2 +- tailwindcss (TAILWINDCSS) - v4 +- vue (VUE) - v3 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 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 Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +

+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- **Unit tests** MUST use mocking and avoid database. They can run outside Docker. +- **Feature tests** can use database but MUST run inside Docker container. +- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces. +- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +**IMPORTANT**: Always run tests in the correct environment based on database dependencies: + +**Unit Tests (no database):** +- Run outside Docker: `./vendor/bin/pest tests/Unit` +- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php` +- These tests use mocking and don't require PostgreSQL + +**Feature Tests (with database):** +- Run inside Docker: `docker exec coolify php artisan test` +- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php` +- Filter by name: `docker exec coolify php artisan test --filter=testName` +- These tests require PostgreSQL and use factories/migrations + +**General Guidelines:** +- Run the minimal number of tests using an appropriate filter before finalizing code edits +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite +- If you get database connection errors, you're running a Feature test outside Docker - move it inside + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. +- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker) +- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker) +- Choose the correct test type based on database dependency: + - No database needed? → Unit test with mocking + - Database needed? → Feature test in Docker + + + +Random other things you should remember: +- App\Models\Application::team must return a relationship instance., always use team() \ No newline at end of file diff --git a/README.md b/README.md index cf3dc21c3..9be4130c2 100644 --- a/README.md +++ b/README.md @@ -53,42 +53,46 @@ # Donations ## Big Sponsors -* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management +* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! * [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform -* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform -* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions -* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers -* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions -* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers -* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase -* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers -* [Trieve](https://trieve.ai?ref=coolify.io) - AI-powered search and analytics -* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data -* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions -* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions -* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor -* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [WZ-IT](https://wz-it.com/?ref=coolify.io) - German agency for customised cloud solutions -* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner -* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform -* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions -* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network -* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang -* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers -* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital transformation and web solutions -* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions -* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services -* [MassiveGrid](https://massivegrid.com?ref=coolify.io) - Enterprise cloud hosting solutions -* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers -* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform +* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions +* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner +* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform +* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain +* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale +* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half +* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform +* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers +* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy +* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions +* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions * [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure +* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions +* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions +* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers +* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency +* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions +* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity +* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang +* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting +* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers +* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase +* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data +* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers +* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform +* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform +* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform + ## Small Sponsors +OpenElements +XamanApp UXWizz Evercam Imre Ujlaki diff --git a/SECURITY.md b/SECURITY.md index 0711bf5b5..e491737ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ ## Reporting a Vulnerability If you discover a security vulnerability, please follow these steps: 1. **DO NOT** disclose the vulnerability publicly. -2. Send a detailed report to: `hi@coollabs.io`. +2. Send a detailed report to: `security@coollabs.io`. 3. Include in your report: - A description of the vulnerability - Steps to reproduce the issue diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 01c258b65..ee3398b04 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -15,33 +15,46 @@ class StopApplication public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) { - ray('StopApplication'); - try { - $server = $application->destination->server; - if (! $server->isFunctional()) { - return 'Server is not functional'; - } - - if ($server->isSwarm()) { - instant_remote_process(["docker stack rm {$application->uuid}"], $server); - - return; - } - - $containersToStop = $application->getContainersToStop($previewDeployments); - $application->stopContainers($containersToStop, $server); - - if ($application->build_pack === 'dockercompose') { - $application->deleteConnectedNetworks(); - } - - if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); - } - } catch (\Exception $e) { - return $e->getMessage(); - } finally { - ServiceStatusChanged::dispatch($application->environment->project->team->id); + $servers = collect([$application->destination->server]); + if ($application?->additional_servers?->count() > 0) { + $servers = $servers->merge($application->additional_servers); } + foreach ($servers as $server) { + try { + if (! $server->isFunctional()) { + return 'Server is not functional'; + } + + if ($server->isSwarm()) { + instant_remote_process(["docker stack rm {$application->uuid}"], $server); + + return; + } + + $containers = $previewDeployments + ? getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true) + : getCurrentApplicationContainerStatus($server, $application->id, 0); + + $containersToStop = $containers->pluck('Names')->toArray(); + + foreach ($containersToStop as $containerName) { + instant_remote_process(command: [ + "docker stop --time=30 $containerName", + "docker rm -f $containerName", + ], server: $server, throwError: false); + } + + if ($application->build_pack === 'dockercompose') { + $application->deleteConnectedNetworks(); + } + + if ($dockerCleanup) { + CleanupDocker::dispatch($server, false, false); + } + } catch (\Exception $e) { + return $e->getMessage(); + } + } + ServiceStatusChanged::dispatch($application->environment->project->team->id); } } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index b13b10efd..600b1cb9a 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -25,7 +25,10 @@ public function handle(Application $application, Server $server) $containerName = data_get($container, 'Names'); if ($containerName) { instant_remote_process( - ["docker rm -f {$containerName}"], + [ + "docker stop --time=30 $containerName", + "docker rm -f $containerName", + ], $server ); } diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 8ce9aa1f2..e0dacfbec 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -104,14 +104,11 @@ public function __invoke(): ProcessResult $this->activity->save(); if ($this->call_event_on_finish) { try { - if ($this->call_event_data) { - event(resolve("App\\Events\\$this->call_event_on_finish", [ - 'data' => $this->call_event_data, - ])); + $eventClass = "App\\Events\\$this->call_event_on_finish"; + if (! is_null($this->call_event_data)) { + event(new $eventClass($this->call_event_data)); } else { - event(resolve("App\\Events\\$this->call_event_on_finish", [ - 'userId' => $this->activity->causer_id, - ])); + event(new $eventClass($this->activity->causer_id)); } } catch (\Throwable $e) { Log::error('Error calling event: '.$e->getMessage()); diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index f218fcabb..38f6d7bc8 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -105,6 +105,8 @@ public function handle(StandaloneClickhouse $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index c4a40f020..12fd92792 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -27,6 +27,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $server = data_get($database, 'destination.server'); $containerName = data_get($database, 'uuid'); $proxyContainerName = "{$database->uuid}-proxy"; + $isSSLEnabled = $database->enable_ssl ?? false; + if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { $databaseType = $database->databaseType(); $network = $database->service->uuid; @@ -42,8 +44,17 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St 'standalone-mongodb' => 27017, default => throw new \Exception("Unsupported database type: $databaseType"), }; + if ($isSSLEnabled) { + $internalPort = match ($databaseType) { + 'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6380, + default => $internalPort, + }; + } $configuration_dir = database_proxy_dir($database->uuid); + if (isDev()) { + $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; + } $nginxconf = << [ $proxyContainerName => [ - 'build' => [ - 'context' => $configuration_dir, - 'dockerfile' => 'Dockerfile', - ], 'image' => 'nginx:stable-alpine', 'container_name' => $proxyContainerName, 'restart' => RESTART_MODE, @@ -81,6 +83,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St 'networks' => [ $network, ], + 'volumes' => [ + [ + 'type' => 'bind', + 'source' => "$configuration_dir/nginx.conf", + 'target' => '/etc/nginx/nginx.conf', + ], + ], 'healthcheck' => [ 'test' => [ 'CMD-SHELL', @@ -103,15 +112,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St ]; $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); - $dockerfile_base64 = base64_encode($dockerfile); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); instant_remote_process([ "mkdir -p $configuration_dir", - "echo '{$dockerfile_base64}' | base64 -d | tee $configuration_dir/Dockerfile > /dev/null", "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", "docker compose --project-directory {$configuration_dir} pull", - "docker compose --project-directory {$configuration_dir} up --build -d", + "docker compose --project-directory {$configuration_dir} up -d", ], $server); } } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 38ad99d2e..300221d24 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -192,6 +192,8 @@ public function handle(StandaloneDragonfly $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 59bcd4123..3a2ceebb3 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -208,6 +208,8 @@ public function handle(StandaloneKeydb $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 13dba4b43..8a936c8ae 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -209,6 +209,8 @@ public function handle(StandaloneMariadb $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 870b5b7e5..19699d684 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -260,6 +260,8 @@ public function handle(StandaloneMongodb $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { $this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem'); diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 5d5611e07..25546fa9d 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -57,11 +57,11 @@ public function handle(StandaloneMysql $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -210,6 +210,8 @@ public function handle(StandaloneMysql $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index a40eac17b..ac011acbe 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -185,6 +185,8 @@ public function handle(StandalonePostgresql $database) } } + $command = ['postgres']; + if (filled($this->database->postgres_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'], @@ -195,29 +197,25 @@ public function handle(StandalonePostgresql $database) 'read_only' => true, ]] ); - $docker_compose['services'][$container_name]['command'] = [ - 'postgres', - '-c', - 'config_file=/etc/postgresql/postgresql.conf', - ]; + $command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']); } if ($this->database->enable_ssl) { - $docker_compose['services'][$container_name]['command'] = [ - 'postgres', - '-c', - 'ssl=on', - '-c', - 'ssl_cert_file=/var/lib/postgresql/certs/server.crt', - '-c', - 'ssl_key_file=/var/lib/postgresql/certs/server.key', - ]; + $command = array_merge($command, [ + '-c', 'ssl=on', + '-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt', + '-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key', + ]); } // Add custom docker run options $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (count($command) > 1) { + $docker_compose['services'][$container_name]['command'] = $command; + } + $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; @@ -225,12 +223,16 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); } $this->commands[] = "echo 'Database started.'"; + ray($this->commands); + return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 68a1f3fe3..8a7ae42a4 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -56,11 +56,11 @@ public function handle(StandaloneRedis $database) $this->commands[] = "mkdir -p $this->configuration_dir/ssl"; $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { @@ -205,6 +205,8 @@ public function handle(StandaloneRedis $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + $this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index 5c0cadd55..63f5b1979 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -18,7 +18,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true) { try { $server = $database->destination->server; @@ -27,10 +27,9 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } $this->stopContainer($database, $database->uuid, 30); - if ($isDeleteOperation) { - if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); - } + + if ($dockerCleanup) { + CleanupDocker::dispatch($server, false, false); } if ($database->is_public) { @@ -50,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout = { $server = $database->destination->server; instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", + "docker stop --timeout=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index d7275ca90..f5d5f82b6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -26,6 +26,8 @@ class GetContainersStatus public $server; + protected ?Collection $applicationContainerStatuses; + public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) { $this->containers = $containers; @@ -94,7 +96,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti } $containerStatus = data_get($container, 'State.Status'); $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - $containerStatus = "$containerStatus ($containerHealth)"; + if ($containerStatus === 'restarting') { + $containerStatus = "restarting ($containerHealth)"; + } else { + $containerStatus = "$containerStatus ($containerHealth)"; + } $labels = Arr::undot(format_docker_labels_to_json($labels)); $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { @@ -119,11 +125,16 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti $application = $this->applications->where('id', $applicationId)->first(); if ($application) { $foundApplications[] = $application->id; - $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => $containerStatus]); - } else { - $application->update(['last_online_at' => now()]); + // Store container status for aggregation + if (! isset($this->applicationContainerStatuses)) { + $this->applicationContainerStatuses = collect(); + } + if (! $this->applicationContainerStatuses->has($applicationId)) { + $this->applicationContainerStatuses->put($applicationId, collect()); + } + $containerName = data_get($labels, 'com.docker.compose.service'); + if ($containerName) { + $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); } } else { // Notify user that this container should not be there. @@ -274,24 +285,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($application->status)->startsWith('exited')) { continue; } - $application->update(['status' => 'exited']); - $name = data_get($application, 'name'); - $fqdn = data_get($application, 'fqdn'); - - $containerName = $name ? "$name ($fqdn)" : $fqdn; - - $projectUuid = data_get($application, 'environment.project.uuid'); - $applicationUuid = data_get($application, 'uuid'); - $environment = data_get($application, 'environment.name'); - - if ($projectUuid && $applicationUuid && $environment) { - $url = base_url().'/project/'.$projectUuid.'/'.$environment.'/application/'.$applicationUuid; - } else { - $url = null; + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); + $application->update(['status' => 'exited']); } $notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews); foreach ($notRunningApplicationPreviews as $previewId) { @@ -299,24 +299,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($preview->status)->startsWith('exited')) { continue; } - $preview->update(['status' => 'exited']); - $name = data_get($preview, 'name'); - $fqdn = data_get($preview, 'fqdn'); - - $containerName = $name ? "$name ($fqdn)" : $fqdn; - - $projectUuid = data_get($preview, 'application.environment.project.uuid'); - $environmentName = data_get($preview, 'application.environment.name'); - $applicationUuid = data_get($preview, 'application.uuid'); - - if ($projectUuid && $applicationUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid; - } else { - $url = null; + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); + $preview->update(['status' => 'exited']); } $notRunningDatabases = $databases->pluck('id')->diff($foundDatabases); foreach ($notRunningDatabases as $database) { @@ -342,6 +331,97 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti } // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } + + // Aggregate multi-container application statuses + if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { + foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + continue; + } + + $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses); + if ($aggregatedStatus) { + $statusFromDb = $application->status; + if ($statusFromDb !== $aggregatedStatus) { + $application->update(['status' => $aggregatedStatus]); + } else { + $application->update(['last_online_at' => now()]); + } + } + } + } + ServiceChecked::dispatch($this->server->team->id); } + + private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string + { + // Parse docker compose to check for excluded containers + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + // Check if container should be excluded + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + // Filter out excluded containers + $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) { + return ! $excludedContainers->contains($containerName); + }); + + // If all containers are excluded, don't update status + if ($relevantStatuses->isEmpty()) { + return null; + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + + foreach ($relevantStatuses as $status) { + if (str($status)->contains('restarting')) { + $hasRestarting = true; + } elseif (str($status)->contains('running')) { + $hasRunning = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('exited')) { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($hasRestarting) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning && $hasExited) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; + } + + // All containers are exited + return 'exited (unhealthy)'; + } } diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index ea2befd3a..9f97dd0d4 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -40,7 +40,7 @@ public function create(array $input): User $user = User::create([ 'id' => 0, 'name' => $input['name'], - 'email' => strtolower($input['email']), + 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $team = $user->teams()->first(); @@ -52,7 +52,7 @@ public function create(array $input): User } else { $user = User::create([ 'name' => $input['name'], - 'email' => strtolower($input['email']), + 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $team = $user->teams()->first(); diff --git a/app/Actions/Proxy/CheckConfiguration.php b/app/Actions/Proxy/CheckConfiguration.php deleted file mode 100644 index bdeafd061..000000000 --- a/app/Actions/Proxy/CheckConfiguration.php +++ /dev/null @@ -1,33 +0,0 @@ -proxyType(); - if ($proxyType === 'NONE') { - return 'OK'; - } - $proxy_path = $server->proxyPath(); - $payload = [ - "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml", - ]; - $proxy_configuration = instant_remote_process($payload, $server, false); - if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); - } - if (! $proxy_configuration || is_null($proxy_configuration)) { - throw new \Exception('Could not generate proxy configuration'); - } - - return $proxy_configuration; - } -} diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 5a2562073..99537e606 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -5,6 +5,7 @@ use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -65,24 +66,14 @@ public function handle(Server $server, $fromUI = false): bool if ($server->id === 0) { $ip = 'host.docker.internal'; } - $portsToCheck = ['80', '443']; + $portsToCheck = []; - foreach ($portsToCheck as $port) { - // Use the smart port checker that handles dual-stack properly - if ($this->isPortConflict($server, $port, $proxyContainerName)) { - if ($fromUI) { - throw new \Exception("Port $port is in use.
You must stop the process using this port.

Docs: https://coolify.io/docs
Discord: https://coolify.io/discord"); - } else { - return false; - } - } - } try { if ($server->proxyType() !== ProxyTypes::NONE->value) { - $proxyCompose = CheckConfiguration::run($server); + $proxyCompose = GetProxyConfiguration::run($server); if (isset($proxyCompose)) { $yaml = Yaml::parse($proxyCompose); - $portsToCheck = []; + $configPorts = []; if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { $ports = data_get($yaml, 'services.traefik.ports'); } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { @@ -90,9 +81,11 @@ public function handle(Server $server, $fromUI = false): bool } if (isset($ports)) { foreach ($ports as $port) { - $portsToCheck[] = str($port)->before(':')->value(); + $configPorts[] = str($port)->before(':')->value(); } } + // Combine default ports with config ports + $portsToCheck = array_merge($portsToCheck, $configPorts); } } else { $portsToCheck = []; @@ -103,11 +96,188 @@ public function handle(Server $server, $fromUI = false): bool if (count($portsToCheck) === 0) { return false; } + $portsToCheck = array_values(array_unique($portsToCheck)); + // Check port conflicts in parallel + $conflicts = $this->checkPortConflictsInParallel($server, $portsToCheck, $proxyContainerName); + foreach ($conflicts as $port => $conflict) { + if ($conflict) { + if ($fromUI) { + throw new \Exception("Port $port is in use.
You must stop the process using this port.

Docs: https://coolify.io/docs
Discord: https://coolify.io/discord"); + } else { + return false; + } + } + } return true; } } + /** + * Check multiple ports for conflicts in parallel + * Returns an array with port => conflict_status mapping + */ + private function checkPortConflictsInParallel(Server $server, array $ports, string $proxyContainerName): array + { + if (empty($ports)) { + return []; + } + + try { + // Build concurrent port check commands + $results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) { + foreach ($ports as $port) { + $commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName); + $pool->command($commands['ssh_command'])->timeout(10); + } + }); + + // Process results + $conflicts = []; + + foreach ($ports as $index => $port) { + $result = $results[$index] ?? null; + + if ($result) { + $conflicts[$port] = $this->parsePortCheckResult($result, $port, $proxyContainerName); + } else { + // If process failed, assume no conflict to avoid false positives + $conflicts[$port] = false; + } + } + + return $conflicts; + } catch (\Throwable $e) { + Log::warning('Parallel port checking failed: '.$e->getMessage().'. Falling back to sequential checking.'); + + // Fallback to sequential checking if parallel fails + $conflicts = []; + foreach ($ports as $port) { + $conflicts[$port] = $this->isPortConflict($server, $port, $proxyContainerName); + } + + return $conflicts; + } + } + + /** + * Build the SSH command for checking a specific port + */ + private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array + { + // First check if our own proxy is using this port (which is fine) + $getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'"; + $checkProxyPortScript = " + CONTAINER_ID=\$($getProxyContainerId); + if [ ! -z \"\$CONTAINER_ID\" ]; then + if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q '\"$port/tcp\"'; then + echo 'proxy_using_port'; + exit 0; + fi; + fi; + "; + + // Command sets for different ways to check ports, ordered by preference + $portCheckScript = " + $checkProxyPortScript + + # Try ss command first + if command -v ss >/dev/null 2>&1; then + ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null); + if [ -z \"\$ss_output\" ]; then + echo 'port_free'; + exit 0; + fi; + count=\$(echo \"\$ss_output\" | grep -c ':$port '); + if [ \$count -eq 0 ]; then + echo 'port_free'; + exit 0; + fi; + # Check for dual-stack or docker processes + if [ \$count -le 2 ] && (echo \"\$ss_output\" | grep -q 'docker\\|coolify'); then + echo 'port_free'; + exit 0; + fi; + echo \"port_conflict|\$ss_output\"; + exit 0; + fi; + + # Try netstat as fallback + if command -v netstat >/dev/null 2>&1; then + netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port '); + if [ -z \"\$netstat_output\" ]; then + echo 'port_free'; + exit 0; + fi; + count=\$(echo \"\$netstat_output\" | grep -c 'LISTEN'); + if [ \$count -eq 0 ]; then + echo 'port_free'; + exit 0; + fi; + if [ \$count -le 2 ] && (echo \"\$netstat_output\" | grep -q 'docker\\|coolify'); then + echo 'port_free'; + exit 0; + fi; + echo \"port_conflict|\$netstat_output\"; + exit 0; + fi; + + # Final fallback using nc + if nc -z -w1 127.0.0.1 $port >/dev/null 2>&1; then + echo 'port_conflict|nc_detected'; + else + echo 'port_free'; + fi; + "; + + $sshCommand = \App\Helpers\SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); + + return [ + 'ssh_command' => $sshCommand, + 'script' => $portCheckScript, + ]; + } + + /** + * Parse the result from port check command + */ + private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool + { + $exitCode = $processResult->exitCode(); + $output = trim($processResult->output()); + $errorOutput = trim($processResult->errorOutput()); + + if ($exitCode !== 0) { + return false; + } + + if ($output === 'proxy_using_port' || $output === 'port_free') { + return false; // No conflict + } + + if (str_starts_with($output, 'port_conflict|')) { + $details = substr($output, strlen('port_conflict|')); + + // Additional logic to detect dual-stack scenarios + if ($details !== 'nc_detected') { + // Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6) + $lines = explode("\n", $details); + if (count($lines) <= 2) { + // Look for IPv4 and IPv6 in the listing + if ((strpos($details, '0.0.0.0:'.$port) !== false && strpos($details, ':::'.$port) !== false) || + (strpos($details, '*:'.$port) !== false && preg_match('/\*:'.$port.'.*IPv[46]/', $details))) { + + return false; // This is just a normal dual-stack setup + } + } + } + + return true; // Real port conflict + } + + return false; + } + /** * Smart port checker that handles dual-stack configurations * Returns true only if there's a real port conflict (not just dual-stack) @@ -176,14 +346,11 @@ private function isPortConflict(Server $server, string $port, string $proxyConta // Run the actual check commands $output = instant_remote_process($set['check'], $server, true); - // Parse the output lines $lines = explode("\n", trim($output)); - // Get the detailed output and listener count - $details = trim($lines[0] ?? ''); - $count = intval(trim($lines[1] ?? '0')); - + $details = trim(implode("\n", array_slice($lines, 0, -1))); + $count = intval(trim($lines[count($lines) - 1] ?? '0')); // If no listeners or empty result, port is free if ($count == 0 || empty($details)) { return false; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php new file mode 100644 index 000000000..3aa1d8d34 --- /dev/null +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -0,0 +1,53 @@ +proxyType(); + if ($proxyType === 'NONE') { + return 'OK'; + } + + $proxy_path = $server->proxyPath(); + $proxy_configuration = null; + + // If not forcing regeneration, try to read existing configuration + if (! $forceRegenerate) { + $payload = [ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ]; + $proxy_configuration = instant_remote_process($payload, $server, false); + } + + // Generate default configuration if: + // 1. Force regenerate is requested + // 2. Configuration file doesn't exist or is empty + if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { + // Extract custom commands from existing config before regenerating + $custom_commands = []; + if (! empty(trim($proxy_configuration ?? ''))) { + $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); + } + + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); + } + + if (empty($proxy_configuration)) { + throw new \Exception('Could not get or generate proxy configuration'); + } + + ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration); + + return $proxy_configuration; + } +} diff --git a/app/Actions/Proxy/SaveConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php similarity index 59% rename from app/Actions/Proxy/SaveConfiguration.php rename to app/Actions/Proxy/SaveProxyConfiguration.php index f2de2b3f5..53fbecce2 100644 --- a/app/Actions/Proxy/SaveConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -5,22 +5,21 @@ use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; -class SaveConfiguration +class SaveProxyConfiguration { use AsAction; - public function handle(Server $server, ?string $proxy_settings = null) + public function handle(Server $server, string $configuration): void { - if (is_null($proxy_settings)) { - $proxy_settings = CheckConfiguration::run($server, true); - } $proxy_path = $server->proxyPath(); - $docker_compose_yml_base64 = base64_encode($proxy_settings); + $docker_compose_yml_base64 = base64_encode($configuration); + // Update the saved settings hash $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; $server->save(); - return instant_remote_process([ + // Transfer the configuration file to the server + instant_remote_process([ "mkdir -p $proxy_path", "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", ], $server); diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 1fff55861..8671a5f27 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -3,7 +3,8 @@ namespace App\Actions\Proxy; use App\Enums\ProxyTypes; -use App\Events\ProxyStarted; +use App\Events\ProxyStatusChanged; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Activitylog\Models\Activity; @@ -18,16 +19,22 @@ public function handle(Server $server, bool $async = true, bool $force = false): if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) { return 'OK'; } + $server->proxy->set('status', 'starting'); + $server->save(); + $server->refresh(); + ProxyStatusChangedUI::dispatch($server->team_id); + $commands = collect([]); $proxy_path = $server->proxyPath(); - $configuration = CheckConfiguration::run($server); + $configuration = GetProxyConfiguration::run($server); if (! $configuration) { throw new \Exception('Configuration is not synced'); } - SaveConfiguration::run($server, $configuration); + SaveProxyConfiguration::run($server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); $server->save(); + if ($server->isSwarmManager()) { $commands = $commands->merge([ "mkdir -p $proxy_path/dynamic", @@ -57,20 +64,20 @@ public function handle(Server $server, bool $async = true, bool $force = false): " echo 'Successfully stopped and removed existing coolify-proxy.'", 'fi', "echo 'Starting coolify-proxy.'", - 'docker compose up -d', + 'docker compose up -d --wait --remove-orphans', "echo 'Successfully started coolify-proxy.'", ]); $commands = $commands->merge(connectProxyToNetworks($server)); } if ($async) { - return remote_process($commands, $server, callEventOnFinish: 'ProxyStarted', callEventData: $server); + return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id); } else { instant_remote_process($commands, $server); - $server->proxy->set('status', 'running'); + $server->proxy->set('type', $proxyType); $server->save(); - ProxyStarted::dispatch($server); + ProxyStatusChanged::dispatch($server->id); return 'OK'; } diff --git a/app/Actions/Proxy/StopProxy.php b/app/Actions/Proxy/StopProxy.php index 75b22d236..29cc63b40 100644 --- a/app/Actions/Proxy/StopProxy.php +++ b/app/Actions/Proxy/StopProxy.php @@ -2,7 +2,10 @@ namespace App\Actions\Proxy; +use App\Events\ProxyStatusChanged; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyDashboardCacheService; use Lorisleiva\Actions\Concerns\AsAction; class StopProxy @@ -13,6 +16,9 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30 { try { $containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $server->proxy->status = 'stopping'; + $server->save(); + ProxyStatusChangedUI::dispatch($server->team_id); instant_remote_process(command: [ "docker stop --time=$timeout $containerName", @@ -24,6 +30,9 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30 $server->save(); } catch (\Throwable $e) { return handleError($e); + } finally { + ProxyDashboardCacheService::clearCache($server); + ProxyStatusChanged::dispatch($server->id); } } } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 55b06e863..6823dfb92 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -83,8 +83,6 @@ public function handle(Server $server) $out = $this->parseDnfOutput($output); $out['osId'] = $osId; $out['package_manager'] = $packageManager; - $rebootRequired = instant_remote_process(['LANG=C dnf needs-restarting -r'], $server); - $out['reboot_required'] = $rebootRequired !== '0'; return $out; case 'apt': @@ -94,8 +92,6 @@ public function handle(Server $server) $out = $this->parseAptOutput($output); $out['osId'] = $osId; $out['package_manager'] = $packageManager; - $rebootRequired = instant_remote_process(['LANG=C test -f /var/run/reboot-required && echo "YES" || echo "NO"'], $server); - $out['reboot_required'] = $rebootRequired === 'YES' ? true : false; return $out; default: @@ -106,7 +102,6 @@ public function handle(Server $server) ]; } } catch (\Throwable $e) { - ray('Error:', $e->getMessage()); return [ 'osId' => $osId, diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 754feecb1..392562167 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -11,7 +11,7 @@ class CleanupDocker public string $jobQueue = 'high'; - public function handle(Server $server) + public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) { $settings = instanceSettings(); $realtimeImage = config('constants.coolify.realtime_image'); @@ -36,11 +36,11 @@ public function handle(Server $server) "docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", ]; - if ($server->settings->delete_unused_volumes) { + if ($deleteUnusedVolumes) { $commands[] = 'docker volume prune -af'; } - if ($server->settings->delete_unused_networks) { + if ($deleteUnusedNetworks) { $commands[] = 'docker network prune -f'; } diff --git a/app/Actions/Server/ConfigureCloudflared.php b/app/Actions/Server/ConfigureCloudflared.php index fc04e67a4..d21622bc5 100644 --- a/app/Actions/Server/ConfigureCloudflared.php +++ b/app/Actions/Server/ConfigureCloudflared.php @@ -2,16 +2,16 @@ namespace App\Actions\Server; -use App\Events\CloudflareTunnelConfigured; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class ConfigureCloudflared { use AsAction; - public function handle(Server $server, string $cloudflare_token) + public function handle(Server $server, string $cloudflare_token, string $ssh_domain): Activity { try { $config = [ @@ -24,6 +24,13 @@ public function handle(Server $server, string $cloudflare_token) 'command' => 'tunnel run', 'environment' => [ "TUNNEL_TOKEN={$cloudflare_token}", + 'TUNNEL_METRICS=127.0.0.1:60123', + ], + 'healthcheck' => [ + 'test' => ['CMD', 'cloudflared', 'tunnel', '--metrics', '127.0.0.1:60123', 'ready'], + 'interval' => '5s', + 'timeout' => '30s', + 'retries' => 5, ], ], ], @@ -34,22 +41,20 @@ public function handle(Server $server, string $cloudflare_token) 'mkdir -p /tmp/cloudflared', 'cd /tmp/cloudflared', "echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null", + 'echo Pulling latest Cloudflare Tunnel image.', 'docker compose pull', - 'docker compose down -v --remove-orphans > /dev/null 2>&1', - 'docker compose up -d --remove-orphans', + 'echo Stopping existing Cloudflare Tunnel container.', + 'docker rm -f coolify-cloudflared || true', + 'echo Starting new Cloudflare Tunnel container.', + 'docker compose up --wait --wait-timeout 15 --remove-orphans || docker logs coolify-cloudflared', ]); - instant_remote_process($commands, $server); - } catch (\Throwable $e) { - $server->settings->is_cloudflare_tunnel = false; - $server->settings->save(); - throw $e; - } finally { - CloudflareTunnelConfigured::dispatch($server->team_id); - $commands = collect([ - 'rm -fr /tmp/cloudflared', + return remote_process($commands, $server, callEventOnFinish: 'CloudflareTunnelChanged', callEventData: [ + 'server_id' => $server->id, + 'ssh_domain' => $ssh_domain, ]); - instant_remote_process($commands, $server); + } catch (\Throwable $e) { + throw $e; } } } diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 15c892e75..45ec68abc 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -2,16 +2,102 @@ namespace App\Actions\Server; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Models\Team; +use App\Notifications\Server\HetznerDeletionFailed; +use App\Services\HetznerService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(Server $server) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null) { - StopSentinel::run($server); - $server->forceDelete(); + $server = Server::withTrashed()->find($serverId); + + // Delete from Hetzner even if server is already gone from Coolify + if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) { + $this->deleteFromHetznerById( + $hetznerServerId ?? $server->hetzner_server_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + + ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); + + // If server is already deleted from Coolify, skip this part + if (! $server) { + return; // Server already force deleted from Coolify + } + + ray('force deleting server from Coolify', ['server_id' => $server->id]); + + try { + $server->forceDelete(); + } catch (\Throwable $e) { + ray('Failed to force delete server from Coolify', [ + 'error' => $e->getMessage(), + 'server_id' => $server->id, + ]); + logger()->error('Failed to force delete server from Coolify', [ + 'error' => $e->getMessage(), + 'server_id' => $server->id, + ]); + } + } + + private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + // Use the provided token, or fallback to first available team token + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::find($cloudProviderTokenId); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'hetzner') + ->first(); + } + + if (! $token) { + ray('No Hetzner token found for team, skipping Hetzner deletion', [ + 'team_id' => $teamId, + 'hetzner_server_id' => $hetznerServerId, + ]); + + return; + } + + $hetznerService = new HetznerService($token->token); + $hetznerService->deleteServer($hetznerServerId); + + ray('Deleted server from Hetzner', [ + 'hetzner_server_id' => $hetznerServerId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + ray('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $hetznerServerId, + 'team_id' => $teamId, + ]); + + // Log the error but don't prevent the server from being deleted from Coolify + logger()->error('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $hetznerServerId, + 'team_id' => $teamId, + ]); + + // Notify the team about the failure + $team = Team::find($teamId); + $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); + } } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 5410b1cbd..10589c8b9 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -4,7 +4,6 @@ use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneDocker; use Lorisleiva\Actions\Concerns\AsAction; @@ -20,7 +19,7 @@ public function handle(Server $server) throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); } - if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) { + if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) { $serverCert = SslHelper::generateSslCertificate( commonName: 'Coolify CA Certificate', serverId: $server->id, diff --git a/app/Actions/Server/ServerCheck.php b/app/Actions/Server/ServerCheck.php deleted file mode 100644 index 41781d7fc..000000000 --- a/app/Actions/Server/ServerCheck.php +++ /dev/null @@ -1,268 +0,0 @@ -server = $server; - try { - if ($this->server->isFunctional() === false) { - return 'Server is not functional.'; - } - - if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) { - - if (isset($data)) { - $data = collect($data); - - $this->server->sentinelHeartbeat(); - - $this->containers = collect(data_get($data, 'containers')); - - $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); - - $containerReplicates = null; - $this->isSentinel = true; - } else { - ['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers(); - // ServerStorageCheckJob::dispatch($this->server); - } - - if (is_null($this->containers)) { - return 'No containers found.'; - } - - if (isset($containerReplicates)) { - foreach ($containerReplicates as $containerReplica) { - $name = data_get($containerReplica, 'Name'); - $this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) { - if (data_get($container, 'Spec.Name') === $name) { - $replicas = data_get($containerReplica, 'Replicas'); - $running = str($replicas)->explode('/')[0]; - $total = str($replicas)->explode('/')[1]; - if ($running === $total) { - data_set($container, 'State.Status', 'running'); - data_set($container, 'State.Health.Status', 'healthy'); - } else { - data_set($container, 'State.Status', 'starting'); - data_set($container, 'State.Health.Status', 'unhealthy'); - } - } - - return $container; - }); - } - } - $this->checkContainers(); - - if ($this->server->isSentinelEnabled() && $this->isSentinel === false) { - CheckAndStartSentinelJob::dispatch($this->server); - } - - if ($this->server->isLogDrainEnabled()) { - $this->checkLogDrainContainer(); - } - - if ($this->server->proxySet() && ! $this->server->proxy->force_stop) { - $foundProxyContainer = $this->containers->filter(function ($value, $key) { - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik'; - } else { - return data_get($value, 'Name') === '/coolify-proxy'; - } - })->first(); - $proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited'); - if (! $foundProxyContainer || $proxyStatus !== 'running') { - try { - $shouldStart = CheckProxy::run($this->server); - if ($shouldStart) { - StartProxy::run($this->server, false); - $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); - } - } catch (\Throwable $e) { - } - } else { - $this->server->proxy->status = data_get($foundProxyContainer, 'State.Status'); - $this->server->save(); - $connectProxyToDockerNetworks = connectProxyToNetworks($this->server); - instant_remote_process($connectProxyToDockerNetworks, $this->server, false); - } - } - } - } catch (\Throwable $e) { - return handleError($e); - } - } - - private function checkLogDrainContainer() - { - $foundLogDrainContainer = $this->containers->filter(function ($value, $key) { - return data_get($value, 'Name') === '/coolify-log-drain'; - })->first(); - if ($foundLogDrainContainer) { - $status = data_get($foundLogDrainContainer, 'State.Status'); - if ($status !== 'running') { - StartLogDrain::dispatch($this->server); - } - } else { - StartLogDrain::dispatch($this->server); - } - } - - private function checkContainers() - { - foreach ($this->containers as $container) { - if ($this->isSentinel) { - $labels = Arr::undot(data_get($container, 'labels')); - } else { - if ($this->server->isSwarm()) { - $labels = Arr::undot(data_get($container, 'Spec.Labels')); - } else { - $labels = Arr::undot(data_get($container, 'Config.Labels')); - } - } - $managed = data_get($labels, 'coolify.managed'); - if (! $managed) { - continue; - } - $uuid = data_get($labels, 'coolify.name'); - if (! $uuid) { - $uuid = data_get($labels, 'com.docker.compose.service'); - } - - if ($this->isSentinel) { - $containerStatus = data_get($container, 'state'); - $containerHealth = data_get($container, 'health_status'); - } else { - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - } - $containerStatus = "$containerStatus ($containerHealth)"; - - $applicationId = data_get($labels, 'coolify.applicationId'); - $serviceId = data_get($labels, 'coolify.serviceId'); - $databaseId = data_get($labels, 'coolify.databaseId'); - $pullRequestId = data_get($labels, 'coolify.pullRequestId'); - - if ($applicationId) { - // Application - if ($pullRequestId != 0) { - if (str($applicationId)->contains('-')) { - $applicationId = str($applicationId)->before('-'); - } - $preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first(); - if ($preview) { - $preview->update(['status' => $containerStatus]); - } - } else { - $application = Application::where('id', $applicationId)->first(); - if ($application) { - $application->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - } - } - } elseif (isset($serviceId)) { - // Service - $subType = data_get($labels, 'coolify.service.subType'); - $subId = data_get($labels, 'coolify.service.subId'); - $service = Service::where('id', $serviceId)->first(); - if (! $service) { - continue; - } - if ($subType === 'application') { - $service = ServiceApplication::where('id', $subId)->first(); - } else { - $service = ServiceDatabase::where('id', $subId)->first(); - } - if ($service) { - $service->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - if ($subType === 'database') { - $isPublic = data_get($service, 'is_public'); - if ($isPublic) { - $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { - if ($this->isSentinel) { - return data_get($value, 'name') === $uuid.'-proxy'; - } else { - - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid"; - } else { - return data_get($value, 'Name') === "/$uuid-proxy"; - } - } - })->first(); - if (! $foundTcpProxy) { - StartDatabaseProxy::run($service); - } - } - } - } - } else { - // Database - if (is_null($this->databases)) { - $this->databases = $this->server->databases(); - } - $database = $this->databases->where('uuid', $uuid)->first(); - if ($database) { - $database->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - - $isPublic = data_get($database, 'is_public'); - if ($isPublic) { - $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { - if ($this->isSentinel) { - return data_get($value, 'name') === $uuid.'-proxy'; - } else { - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid"; - } else { - - return data_get($value, 'Name') === "/$uuid-proxy"; - } - } - })->first(); - if (! $foundTcpProxy) { - StartDatabaseProxy::run($database); - // $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server)); - } - } - } - } - } - } -} diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 2785505c0..1f248aec1 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Events\SentinelRestarted; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,7 +10,7 @@ class StartSentinel { use AsAction; - public function handle(Server $server, bool $restart = false, ?string $latestVersion = null) + public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null) { if ($server->isSwarm() || $server->isBuildServer()) { return; @@ -27,7 +28,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $mountDir = '/data/coolify/sentinel'; $image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version; if (! $endpoint) { - throw new \Exception('You should set FQDN in Instance Settings.'); + throw new \RuntimeException('You should set FQDN in Instance Settings.'); } $environments = [ 'TOKEN' => $token, @@ -43,7 +44,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer ]; if (isDev()) { // data_set($environments, 'DEBUG', 'true'); - // $image = 'sentinel'; + if ($customImage && ! empty($customImage)) { + $image = $customImage; + } $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; } $dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"'; @@ -61,5 +64,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $server->settings->is_sentinel_enabled = true; $server->settings->save(); $server->sentinelHeartbeat(); + + // Dispatch event to notify UI components + SentinelRestarted::dispatch($server, $version); } } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 9a6cc140b..2a06428e2 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -29,7 +29,7 @@ public function handle($manual_update = false) if (! $this->server) { return; } - CleanupDocker::dispatch($this->server); + CleanupDocker::dispatch($this->server, false, false); $this->latestVersion = get_latest_version_of_coolify(); $this->currentVersion = config('constants.coolify.version'); if (! $manual_update) { diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 372ffe397..8790901cd 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -11,7 +11,7 @@ class DeleteService { use AsAction; - public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) + public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup) { try { $server = data_get($service, 'server'); @@ -53,7 +53,7 @@ public function handle(Service $service, bool $deleteConfigurations, bool $delet instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); } catch (\Exception $e) { - throw new \Exception($e->getMessage()); + throw new \RuntimeException($e->getMessage()); } finally { if ($deleteConfigurations) { $service->deleteConfigurations(); @@ -71,7 +71,7 @@ public function handle(Service $service, bool $deleteConfigurations, bool $delet $service->forceDelete(); if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } } } diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php index 4151ea947..d38ef54d6 100644 --- a/app/Actions/Service/RestartService.php +++ b/app/Actions/Service/RestartService.php @@ -11,10 +11,10 @@ class RestartService public string $jobQueue = 'high'; - public function handle(Service $service) + public function handle(Service $service, bool $pullLatestImages) { StopService::run($service); - return StartService::run($service); + return StartService::run($service, $pullLatestImages); } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 1e7779a75..dfef6a566 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -19,6 +19,7 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s StopService::run(service: $service, dockerCleanup: false); } $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); $commands[] = 'cd '.$service->workdir(); $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'"; if ($pullLatestImages) { @@ -41,6 +42,6 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s } } - return remote_process($commands, $service->server, type_uuid: $service->uuid); + return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 9638db57f..3f4e96479 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -4,6 +4,7 @@ use App\Actions\Server\CleanupDocker; use App\Events\ServiceStatusChanged; +use App\Models\Server; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; @@ -13,7 +14,7 @@ class StopService public string $jobQueue = 'high'; - public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) + public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true) { try { $server = $service->destination->server; @@ -21,14 +22,25 @@ public function handle(Service $service, bool $isDeleteOperation = false, bool $ return 'Server is not functional'; } - $containersToStop = $service->getContainersToStop(); - $service->stopContainers($containersToStop, $server); + $containersToStop = []; + $applications = $service->applications()->get(); + foreach ($applications as $application) { + $containersToStop[] = "{$application->name}-{$service->uuid}"; + } + $dbs = $service->databases()->get(); + foreach ($dbs as $db) { + $containersToStop[] = "{$db->name}-{$service->uuid}"; + } - if ($isDeleteOperation) { + if (! empty($containersToStop)) { + $this->stopContainersInParallel($containersToStop, $server); + } + + if ($deleteConnectedNetworks) { $service->deleteConnectedNetworks(); - if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); - } + } + if ($dockerCleanup) { + CleanupDocker::dispatch($server, false, false); } } catch (\Exception $e) { return $e->getMessage(); @@ -36,4 +48,18 @@ public function handle(Service $service, bool $isDeleteOperation = false, bool $ ServiceStatusChanged::dispatch($service->environment->project->team->id); } } + + private function stopContainersInParallel(array $containersToStop, Server $server): void + { + $timeout = count($containersToStop) > 5 ? 10 : 30; + $commands = []; + $containerList = implode(' ', $containersToStop); + $commands[] = "docker stop --time=$timeout $containerList"; + $commands[] = "docker rm -f $containerList"; + instant_remote_process( + command: $commands, + server: $server, + throwError: false + ); + } } diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index 5a7ba6637..e06136e3c 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -26,22 +26,22 @@ public function handle(Application $application) continue; } } - $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); - $container = format_docker_command_output_to_json($container); - if ($container->count() === 1) { - $container = $container->first(); - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); + $containers = format_docker_command_output_to_json($containers); + + if ($containers->count() > 0) { + $statusToSet = $this->aggregateContainerStatuses($application, $containers); + if ($is_main_server) { $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $application->update(['status' => $statusToSet]); } } else { $additional_server = $application->additional_servers()->wherePivot('server_id', $server->id); $statusFromDb = $additional_server->first()->pivot->status; - if ($statusFromDb !== $containerStatus) { - $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]); } } } else { @@ -57,4 +57,78 @@ public function handle(Application $application) } } } + + private function aggregateContainerStatuses($application, $containers) + { + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + $relevantContainerCount = 0; + + foreach ($containers as $container) { + $labels = data_get($container, 'Config.Labels', []); + $serviceName = data_get($labels, 'com.docker.compose.service'); + + if ($serviceName && $excludedContainers->contains($serviceName)) { + continue; + } + + $relevantContainerCount++; + $containerStatus = data_get($container, 'State.Status'); + $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + + if ($containerStatus === 'restarting') { + $hasRestarting = true; + $hasUnhealthy = true; + } elseif ($containerStatus === 'running') { + $hasRunning = true; + if ($containerHealth === 'unhealthy') { + $hasUnhealthy = true; + } + } elseif ($containerStatus === 'exited') { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($relevantContainerCount === 0) { + return 'running:healthy'; + } + + if ($hasRestarting) { + return 'degraded:unhealthy'; + } + + if ($hasRunning && $hasExited) { + return 'degraded:unhealthy'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy'; + } + + return 'exited:unhealthy'; + } } diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php new file mode 100644 index 000000000..71b6ed52b --- /dev/null +++ b/app/Actions/Stripe/CancelSubscription.php @@ -0,0 +1,209 @@ +user = $user; + $this->isDryRun = $isDryRun; + + if (! $isDryRun && isCloud()) { + $this->stripe = new StripeClient(config('subscription.stripe_api_key')); + } + } + + public function getSubscriptionsPreview(): Collection + { + $subscriptions = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams()->get(); + + foreach ($teams as $team) { + // Only include subscriptions from teams where user is owner + $userRole = $team->pivot->role; + if ($userRole === 'owner' && $team->subscription) { + $subscription = $team->subscription; + + // Only include active subscriptions + if ($subscription->stripe_subscription_id && + $subscription->stripe_invoice_paid) { + $subscriptions->push($subscription); + } + } + } + + return $subscriptions; + } + + /** + * Verify subscriptions exist and are active in Stripe API + * + * @return array ['verified' => Collection, 'not_found' => Collection, 'errors' => array] + */ + public function verifySubscriptionsInStripe(): array + { + if (! isCloud()) { + return [ + 'verified' => collect(), + 'not_found' => collect(), + 'errors' => [], + ]; + } + + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $subscriptions = $this->getSubscriptionsPreview(); + + $verified = collect(); + $notFound = collect(); + $errors = []; + + foreach ($subscriptions as $subscription) { + try { + $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + + // Check if subscription is actually active in Stripe + if (in_array($stripeSubscription->status, ['active', 'trialing', 'past_due'])) { + $verified->push([ + 'subscription' => $subscription, + 'stripe_status' => $stripeSubscription->status, + 'current_period_end' => $stripeSubscription->current_period_end, + ]); + } else { + $notFound->push([ + 'subscription' => $subscription, + 'reason' => "Status in Stripe: {$stripeSubscription->status}", + ]); + } + } catch (\Stripe\Exception\InvalidRequestException $e) { + // Subscription doesn't exist in Stripe + $notFound->push([ + 'subscription' => $subscription, + 'reason' => 'Not found in Stripe', + ]); + } catch (\Exception $e) { + $errors[] = "Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage(); + \Log::error("Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage()); + } + } + + return [ + 'verified' => $verified, + 'not_found' => $notFound, + 'errors' => $errors, + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'cancelled' => 0, + 'failed' => 0, + 'errors' => [], + ]; + } + + $cancelledCount = 0; + $failedCount = 0; + $errors = []; + + $subscriptions = $this->getSubscriptionsPreview(); + + foreach ($subscriptions as $subscription) { + try { + $this->cancelSingleSubscription($subscription); + $cancelledCount++; + } catch (\Exception $e) { + $failedCount++; + $errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage(); + $errors[] = $errorMessage; + \Log::error($errorMessage); + } + } + + return [ + 'cancelled' => $cancelledCount, + 'failed' => $failedCount, + 'errors' => $errors, + ]; + } + + private function cancelSingleSubscription(Subscription $subscription): void + { + if (! $this->stripe) { + throw new \Exception('Stripe client not initialized'); + } + + $subscriptionId = $subscription->stripe_subscription_id; + + // Cancel the subscription immediately (not at period end) + $this->stripe->subscriptions->cancel($subscriptionId, []); + + // Update local database + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'User account deleted', + 'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(), + ]); + + // Call the team's subscription ended method to handle cleanup + if ($subscription->team) { + $subscription->team->subscriptionEnded(); + } + + \Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}"); + } + + /** + * Cancel a single subscription by ID (helper method for external use) + */ + public static function cancelById(string $subscriptionId): bool + { + try { + if (! isCloud()) { + return false; + } + + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe->subscriptions->cancel($subscriptionId, []); + + // Update local record if exists + $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first(); + if ($subscription) { + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + ]); + + if ($subscription->team) { + $subscription->team->subscriptionEnded(); + } + } + + return true; + } catch (\Exception $e) { + \Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage()); + + return false; + } + } +} diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php new file mode 100644 index 000000000..3c539d7c5 --- /dev/null +++ b/app/Actions/User/DeleteUserResources.php @@ -0,0 +1,131 @@ +user = $user; + $this->isDryRun = $isDryRun; + } + + public function getResourcesPreview(): array + { + $applications = collect(); + $databases = collect(); + $services = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams()->get(); + + foreach ($teams as $team) { + // Only delete resources from teams that will be FULLY DELETED + // This means: user is the ONLY member of the team + // + // DO NOT delete resources if: + // - User is just a member (not owner) + // - Team has other members (ownership will be transferred or user just removed) + + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + // Skip if user is not owner + if ($userRole !== 'owner') { + continue; + } + + // Skip if team has other members (will be transferred/user removed, not deleted) + if ($memberCount > 1) { + continue; + } + + // Only delete resources from teams where user is the ONLY member + // These teams will be fully deleted + + // Get all servers for this team + $servers = $team->servers()->get(); + + foreach ($servers as $server) { + // Get applications (custom method returns Collection) + $serverApplications = $server->applications(); + $applications = $applications->merge($serverApplications); + + // Get databases (custom method returns Collection) + $serverDatabases = $server->databases(); + $databases = $databases->merge($serverDatabases); + + // Get services (relationship needs ->get()) + $serverServices = $server->services()->get(); + $services = $services->merge($serverServices); + } + } + + return [ + 'applications' => $applications->unique('id'), + 'databases' => $databases->unique('id'), + 'services' => $services->unique('id'), + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'applications' => 0, + 'databases' => 0, + 'services' => 0, + ]; + } + + $deletedCounts = [ + 'applications' => 0, + 'databases' => 0, + 'services' => 0, + ]; + + $resources = $this->getResourcesPreview(); + + // Delete applications + foreach ($resources['applications'] as $application) { + try { + $application->forceDelete(); + $deletedCounts['applications']++; + } catch (\Exception $e) { + \Log::error("Failed to delete application {$application->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Delete databases + foreach ($resources['databases'] as $database) { + try { + $database->forceDelete(); + $deletedCounts['databases']++; + } catch (\Exception $e) { + \Log::error("Failed to delete database {$database->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Delete services + foreach ($resources['services'] as $service) { + try { + $service->forceDelete(); + $deletedCounts['services']++; + } catch (\Exception $e) { + \Log::error("Failed to delete service {$service->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return $deletedCounts; + } +} diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php new file mode 100644 index 000000000..ca29dd49d --- /dev/null +++ b/app/Actions/User/DeleteUserServers.php @@ -0,0 +1,77 @@ +user = $user; + $this->isDryRun = $isDryRun; + } + + public function getServersPreview(): Collection + { + $servers = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams()->get(); + + foreach ($teams as $team) { + // Only include servers from teams where user is owner or admin + $userRole = $team->pivot->role; + if ($userRole === 'owner' || $userRole === 'admin') { + $teamServers = $team->servers()->get(); + $servers = $servers->merge($teamServers); + } + } + + // Return unique servers (in case same server is in multiple teams) + return $servers->unique('id'); + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'servers' => 0, + ]; + } + + $deletedCount = 0; + + $servers = $this->getServersPreview(); + + foreach ($servers as $server) { + try { + // Skip the default server (ID 0) which is the Coolify host + if ($server->id === 0) { + \Log::info('Skipping deletion of Coolify host server (ID: 0)'); + + continue; + } + + // The Server model's forceDeleting event will handle cleanup of: + // - destinations + // - settings + $server->forceDelete(); + $deletedCount++; + } catch (\Exception $e) { + \Log::error("Failed to delete server {$server->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return [ + 'servers' => $deletedCount, + ]; + } +} diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php new file mode 100644 index 000000000..d572db9e7 --- /dev/null +++ b/app/Actions/User/DeleteUserTeams.php @@ -0,0 +1,202 @@ +user = $user; + $this->isDryRun = $isDryRun; + } + + public function getTeamsPreview(): array + { + $teamsToDelete = collect(); + $teamsToTransfer = collect(); + $teamsToLeave = collect(); + $edgeCases = collect(); + + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Skip root team (ID 0) + if ($team->id === 0) { + continue; + } + + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + if ($memberCount === 1) { + // User is alone in the team - delete it + $teamsToDelete->push($team); + } elseif ($userRole === 'owner') { + // Check if there are other owners + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + // There are other owners, but check if this user is paying for the subscription + if ($this->isUserPayingForTeamSubscription($team)) { + // User is paying for the subscription - this is an edge case + $edgeCases->push([ + 'team' => $team, + 'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.', + ]); + } else { + // There are other owners and user is not paying, just remove this user + $teamsToLeave->push($team); + } + } else { + // User is the only owner, check for replacement + $newOwner = $this->findNewOwner($team); + if ($newOwner) { + $teamsToTransfer->push([ + 'team' => $team, + 'new_owner' => $newOwner, + ]); + } else { + // No suitable replacement found - this is an edge case + $edgeCases->push([ + 'team' => $team, + 'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.', + ]); + } + } + } else { + // User is just a member - remove them from the team + $teamsToLeave->push($team); + } + } + + return [ + 'to_delete' => $teamsToDelete, + 'to_transfer' => $teamsToTransfer, + 'to_leave' => $teamsToLeave, + 'edge_cases' => $edgeCases, + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'deleted' => 0, + 'transferred' => 0, + 'left' => 0, + ]; + } + + $counts = [ + 'deleted' => 0, + 'transferred' => 0, + 'left' => 0, + ]; + + $preview = $this->getTeamsPreview(); + + // Check for edge cases - should not happen here as we check earlier, but be safe + if ($preview['edge_cases']->isNotEmpty()) { + throw new \Exception('Edge cases detected during execution. This should not happen.'); + } + + // Delete teams where user is alone + foreach ($preview['to_delete'] as $team) { + try { + // The Team model's deleting event will handle cleanup of: + // - private keys + // - sources + // - tags + // - environment variables + // - s3 storages + // - notification settings + $team->delete(); + $counts['deleted']++; + } catch (\Exception $e) { + \Log::error("Failed to delete team {$team->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Transfer ownership for teams where user is owner but not alone + foreach ($preview['to_transfer'] as $item) { + try { + $team = $item['team']; + $newOwner = $item['new_owner']; + + // Update the new owner's role to owner + $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); + + // Remove the current user from the team + $team->members()->detach($this->user->id); + + $counts['transferred']++; + } catch (\Exception $e) { + \Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Remove user from teams where they're just a member + foreach ($preview['to_leave'] as $team) { + try { + $team->members()->detach($this->user->id); + $counts['left']++; + } catch (\Exception $e) { + \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return $counts; + } + + private function findNewOwner(Team $team): ?User + { + // Only look for admins as potential new owners + // We don't promote regular members automatically + $otherAdmin = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'admin'; + }) + ->first(); + + return $otherAdmin; + } + + private function isUserPayingForTeamSubscription(Team $team): bool + { + if (! $team->subscription || ! $team->subscription->stripe_customer_id) { + return false; + } + + // In Stripe, we need to check if the customer email matches the user's email + // This would require a Stripe API call to get customer details + // For now, we'll check if the subscription was created by this user + + // Alternative approach: Check if user is the one who initiated the subscription + // We could store this information when the subscription is created + // For safety, we'll assume if there's an active subscription and multiple owners, + // we should treat it as an edge case that needs manual review + + if ($team->subscription->stripe_subscription_id && + $team->subscription->stripe_invoice_paid) { + // Active subscription exists - we should be cautious + return true; + } + + return false; + } +} diff --git a/app/Console/Commands/AdminDeleteUser.php b/app/Console/Commands/AdminDeleteUser.php new file mode 100644 index 000000000..9b803b1f7 --- /dev/null +++ b/app/Console/Commands/AdminDeleteUser.php @@ -0,0 +1,1173 @@ + false, + 'phase_2_resources' => false, + 'phase_3_servers' => false, + 'phase_4_teams' => false, + 'phase_5_user_profile' => false, + 'phase_6_stripe' => false, + 'db_committed' => false, + ]; + + public function handle() + { + // Register signal handlers for graceful shutdown (Ctrl+C handling) + $this->registerSignalHandlers(); + + $email = $this->argument('email'); + $this->isDryRun = $this->option('dry-run'); + $this->skipStripe = $this->option('skip-stripe'); + $this->skipResources = $this->option('skip-resources'); + $force = $this->option('force'); + + if ($force) { + $this->warn('⚠️ FORCE MODE - Lock check will be bypassed'); + $this->warn(' Use this flag only if you are certain no other deletion is running'); + $this->newLine(); + } + + if ($this->isDryRun) { + $this->info('🔍 DRY RUN MODE - No data will be deleted'); + $this->newLine(); + } + + if ($this->output->isVerbose()) { + $this->info('📊 VERBOSE MODE - Full stack traces will be shown on errors'); + $this->newLine(); + } else { + $this->comment('💡 Tip: Use -v flag for detailed error stack traces'); + $this->newLine(); + } + + if (! $this->isDryRun && ! $this->option('auto-confirm')) { + $this->info('🔄 INTERACTIVE MODE - You will be asked to confirm after each phase'); + $this->comment(' Use --auto-confirm to skip phase confirmations'); + $this->newLine(); + } + + // Notify about instance type and Stripe + if (isCloud()) { + $this->comment('☁️ Cloud instance - Stripe subscriptions will be handled'); + } else { + $this->comment('🏠 Self-hosted instance - Stripe operations will be skipped'); + } + $this->newLine(); + + try { + $this->user = User::whereEmail($email)->firstOrFail(); + } catch (\Exception $e) { + $this->error("User with email '{$email}' not found."); + + return 1; + } + + // Implement file lock to prevent concurrent deletions of the same user + $lockKey = "user_deletion_{$this->user->id}"; + $this->lock = Cache::lock($lockKey, 600); // 10 minute lock + + if (! $force) { + if (! $this->lock->get()) { + $this->error('Another deletion process is already running for this user.'); + $this->error('Use --force to bypass this lock (use with extreme caution).'); + $this->logAction("Deletion blocked for user {$email}: Another process is already running"); + + return 1; + } + } else { + // In force mode, try to get lock but continue even if it fails + if (! $this->lock->get()) { + $this->warn('⚠️ Lock exists but proceeding due to --force flag'); + $this->warn(' There may be another deletion process running!'); + $this->newLine(); + } + } + + try { + $this->logAction("Starting user deletion process for: {$email}"); + + // Phase 1: Show User Overview (outside transaction) + if (! $this->showUserOverview()) { + $this->info('User deletion cancelled by operator.'); + + return 0; + } + $this->deletionState['phase_1_overview'] = true; + + // If not dry run, wrap DB operations in a transaction + // NOTE: Stripe cancellations happen AFTER commit to avoid inconsistent state + if (! $this->isDryRun) { + try { + DB::beginTransaction(); + + // Phase 2: Delete Resources + // WARNING: This triggers Docker container deletion via SSH which CANNOT be rolled back + if (! $this->skipResources) { + if (! $this->deleteResources()) { + DB::rollBack(); + $this->displayErrorState('Phase 2: Resource Deletion'); + $this->error('❌ User deletion failed at resource deletion phase.'); + $this->warn('⚠️ Some Docker containers may have been deleted on remote servers and cannot be restored.'); + $this->displayRecoverySteps(); + + return 1; + } + } + $this->deletionState['phase_2_resources'] = true; + + // Confirmation to continue after Phase 2 + if (! $this->skipResources && ! $this->option('auto-confirm')) { + $this->newLine(); + if (! $this->confirm('Phase 2 completed. Continue to Phase 3 (Delete Servers)?', true)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator after Phase 2.'); + $this->info('Database changes have been rolled back.'); + + return 0; + } + } + + // Phase 3: Delete Servers + // WARNING: This may trigger cleanup operations on remote servers which CANNOT be rolled back + if (! $this->deleteServers()) { + DB::rollBack(); + $this->displayErrorState('Phase 3: Server Deletion'); + $this->error('❌ User deletion failed at server deletion phase.'); + $this->warn('⚠️ Some server cleanup operations may have been performed and cannot be restored.'); + $this->displayRecoverySteps(); + + return 1; + } + $this->deletionState['phase_3_servers'] = true; + + // Confirmation to continue after Phase 3 + if (! $this->option('auto-confirm')) { + $this->newLine(); + if (! $this->confirm('Phase 3 completed. Continue to Phase 4 (Handle Teams)?', true)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator after Phase 3.'); + $this->info('Database changes have been rolled back.'); + + return 0; + } + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + DB::rollBack(); + $this->displayErrorState('Phase 4: Team Handling'); + $this->error('❌ User deletion failed at team handling phase.'); + $this->displayRecoverySteps(); + + return 1; + } + $this->deletionState['phase_4_teams'] = true; + + // Confirmation to continue after Phase 4 + if (! $this->option('auto-confirm')) { + $this->newLine(); + if (! $this->confirm('Phase 4 completed. Continue to Phase 5 (Delete User Profile)?', true)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator after Phase 4.'); + $this->info('Database changes have been rolled back.'); + + return 0; + } + } + + // Phase 5: Delete User Profile + if (! $this->deleteUserProfile()) { + DB::rollBack(); + $this->displayErrorState('Phase 5: User Profile Deletion'); + $this->error('❌ User deletion failed at user profile deletion phase.'); + $this->displayRecoverySteps(); + + return 1; + } + $this->deletionState['phase_5_user_profile'] = true; + + // CRITICAL CONFIRMATION: Database commit is next (PERMANENT) + if (! $this->option('auto-confirm')) { + $this->newLine(); + $this->warn('⚠️ CRITICAL DECISION POINT'); + $this->warn('Next step: COMMIT database changes (PERMANENT and IRREVERSIBLE)'); + $this->warn('All resources, servers, teams, and user profile will be permanently deleted'); + $this->newLine(); + if (! $this->confirm('Phase 5 completed. Commit database changes? (THIS IS PERMANENT)', false)) { + DB::rollBack(); + $this->info('User deletion cancelled by operator before commit.'); + $this->info('Database changes have been rolled back.'); + $this->warn('⚠️ Note: Some Docker containers may have been deleted on remote servers.'); + + return 0; + } + } + + // Commit the database transaction + DB::commit(); + $this->deletionState['db_committed'] = true; + + $this->newLine(); + $this->info('✅ Database operations completed successfully!'); + $this->info('✅ Transaction committed - database changes are now PERMANENT.'); + $this->logAction("Database deletion completed for: {$email}"); + + // Confirmation to continue to Stripe (after commit) + if (! $this->skipStripe && isCloud() && ! $this->option('auto-confirm')) { + $this->newLine(); + $this->warn('⚠️ Database changes are committed (permanent)'); + $this->info('Next: Cancel Stripe subscriptions'); + if (! $this->confirm('Continue to Phase 6 (Cancel Stripe Subscriptions)?', true)) { + $this->warn('User deletion stopped after database commit.'); + $this->error('⚠️ IMPORTANT: User deleted from database but Stripe subscriptions remain active!'); + $this->error('You must cancel subscriptions manually in Stripe Dashboard.'); + $this->error('Go to: https://dashboard.stripe.com/'); + $this->error('Search for: '.$email); + + return 1; + } + } + + // Phase 6: Cancel Stripe Subscriptions (AFTER DB commit) + // This is done AFTER commit because Stripe API calls cannot be rolled back + // If this fails, DB changes are already committed but subscriptions remain active + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + $this->newLine(); + $this->error('═══════════════════════════════════════'); + $this->error('⚠️ CRITICAL: INCONSISTENT STATE DETECTED'); + $this->error('═══════════════════════════════════════'); + $this->error('✓ User data DELETED from database (committed)'); + $this->error('✗ Stripe subscription cancellation FAILED'); + $this->newLine(); + $this->displayErrorState('Phase 6: Stripe Cancellation (Post-Commit)'); + $this->newLine(); + $this->error('MANUAL ACTION REQUIRED:'); + $this->error('1. Go to Stripe Dashboard: https://dashboard.stripe.com/'); + $this->error('2. Search for customer email: '.$email); + $this->error('3. Cancel all active subscriptions'); + $this->error('4. Check storage/logs/user-deletions.log for subscription IDs'); + $this->newLine(); + $this->logAction("INCONSISTENT STATE: User {$email} deleted but Stripe cancellation failed"); + + return 1; + } + } + $this->deletionState['phase_6_stripe'] = true; + + $this->newLine(); + $this->info('✅ User deletion completed successfully!'); + $this->logAction("User deletion completed for: {$email}"); + + } catch (\Exception $e) { + DB::rollBack(); + $this->newLine(); + $this->error('═══════════════════════════════════════'); + $this->error('❌ EXCEPTION DURING USER DELETION'); + $this->error('═══════════════════════════════════════'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + $this->newLine(); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + $this->newLine(); + } else { + $this->info('Run with -v for full stack trace'); + $this->newLine(); + } + + $this->displayErrorState('Exception during execution'); + $this->displayRecoverySteps(); + + $this->logAction("User deletion failed for {$email}: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}"); + + return 1; + } + } else { + // Dry run mode - just run through the phases without transaction + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + $this->info('User deletion would be cancelled at resource deletion phase.'); + + return 0; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + $this->info('User deletion would be cancelled at server deletion phase.'); + + return 0; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + $this->info('User deletion would be cancelled at team handling phase.'); + + return 0; + } + + // Phase 5: Delete User Profile + if (! $this->deleteUserProfile()) { + $this->info('User deletion would be cancelled at user profile deletion phase.'); + + return 0; + } + + // Phase 6: Cancel Stripe Subscriptions (shown after DB operations in dry run too) + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + $this->info('User deletion would be cancelled at Stripe cancellation phase.'); + + return 0; + } + } + + $this->newLine(); + $this->info('✅ DRY RUN completed successfully! No data was deleted.'); + } + + return 0; + } finally { + // Ensure lock is always released + $this->releaseLock(); + } + } + + private function showUserOverview(): bool + { + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 1: USER OVERVIEW'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $teams = $this->user->teams()->get(); + $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner'); + $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner'); + + // Collect servers and resources ONLY from teams that will be FULLY DELETED + // This means: user is owner AND is the ONLY member + // + // Resources from these teams will NOT be deleted: + // - Teams where user is just a member + // - Teams where user is owner but has other members (will be transferred/user removed) + $allServers = collect(); + $allApplications = collect(); + $allDatabases = collect(); + $allServices = collect(); + $activeSubscriptions = collect(); + + foreach ($teams as $team) { + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + // Only show resources from teams where user is the ONLY member + // These are the teams that will be fully deleted + if ($userRole !== 'owner' || $memberCount > 1) { + continue; + } + + $servers = $team->servers()->get(); + $allServers = $allServers->merge($servers); + + foreach ($servers as $server) { + $resources = $server->definedResources(); + foreach ($resources as $resource) { + if ($resource instanceof \App\Models\Application) { + $allApplications->push($resource); + } elseif ($resource instanceof \App\Models\Service) { + $allServices->push($resource); + } else { + $allDatabases->push($resource); + } + } + } + + // Only collect subscriptions on cloud instances + if (isCloud() && $team->subscription && $team->subscription->stripe_subscription_id) { + $activeSubscriptions->push($team->subscription); + } + } + + // Build table data + $tableData = [ + ['User', $this->user->email], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')], + ['Teams (Total)', $teams->count()], + ['Teams (Owner)', $ownedTeams->count()], + ['Teams (Member)', $memberTeams->count()], + ['Servers', $allServers->unique('id')->count()], + ['Applications', $allApplications->count()], + ['Databases', $allDatabases->count()], + ['Services', $allServices->count()], + ]; + + // Only show Stripe subscriptions on cloud instances + if (isCloud()) { + $tableData[] = ['Active Stripe Subscriptions', $activeSubscriptions->count()]; + } + + $this->table(['Property', 'Value'], $tableData); + + $this->newLine(); + + $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!'); + $this->newLine(); + + if (! $this->confirm('Do you want to continue with the deletion process?', false)) { + return false; + } + + return true; + } + + private function deleteResources(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 2: DELETE RESOURCES'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserResources($this->user, $this->isDryRun); + $resources = $action->getResourcesPreview(); + + if ($resources['applications']->isEmpty() && + $resources['databases']->isEmpty() && + $resources['services']->isEmpty()) { + $this->info('No resources to delete.'); + + return true; + } + + $this->info('Resources to be deleted:'); + $this->newLine(); + + if ($resources['applications']->isNotEmpty()) { + $this->warn("Applications to be deleted ({$resources['applications']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server', 'Status'], + $resources['applications']->map(function ($app) { + return [ + $app->name, + $app->uuid, + $app->destination->server->name, + $app->status ?? 'unknown', + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['databases']->isNotEmpty()) { + $this->warn("Databases to be deleted ({$resources['databases']->count()}):"); + $this->table( + ['Name', 'Type', 'UUID', 'Server'], + $resources['databases']->map(function ($db) { + return [ + $db->name, + class_basename($db), + $db->uuid, + $db->destination->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['services']->isNotEmpty()) { + $this->warn("Services to be deleted ({$resources['services']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server'], + $resources['services']->map(function ($service) { + return [ + $service->name, + $service->uuid, + $service->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!'); + if (! $this->confirm('Are you sure you want to delete all these resources?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting resources...'); + try { + $result = $action->execute(); + $this->info("✓ Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services"); + $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services"); + } catch (\Exception $e) { + $this->error('Failed to delete resources:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function deleteServers(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 3: DELETE SERVERS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserServers($this->user, $this->isDryRun); + $servers = $action->getServersPreview(); + + if ($servers->isEmpty()) { + $this->info('No servers to delete.'); + + return true; + } + + $this->warn("Servers to be deleted ({$servers->count()}):"); + $this->table( + ['ID', 'Name', 'IP', 'Description', 'Resources Count'], + $servers->map(function ($server) { + $resourceCount = $server->definedResources()->count(); + + return [ + $server->id, + $server->name, + $server->ip, + $server->description ?? '-', + $resourceCount, + ]; + })->toArray() + ); + $this->newLine(); + + $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!'); + if (! $this->confirm('Are you sure you want to delete all these servers?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting servers...'); + try { + $result = $action->execute(); + $this->info("✓ Deleted {$result['servers']} servers"); + $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}"); + } catch (\Exception $e) { + $this->error('Failed to delete servers:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function handleTeams(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 4: HANDLE TEAMS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserTeams($this->user, $this->isDryRun); + $preview = $action->getTeamsPreview(); + + // Check for edge cases first - EXIT IMMEDIATELY if found + if ($preview['edge_cases']->isNotEmpty()) { + $this->error('═══════════════════════════════════════'); + $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED'); + $this->error('═══════════════════════════════════════'); + $this->newLine(); + + foreach ($preview['edge_cases'] as $edgeCase) { + $team = $edgeCase['team']; + $reason = $edgeCase['reason']; + $this->error("Team: {$team->name} (ID: {$team->id})"); + $this->error("Issue: {$reason}"); + + // Show team members for context + $this->info('Current members:'); + foreach ($team->members as $member) { + $role = $member->pivot->role; + $this->line(" - {$member->name} ({$member->email}) - Role: {$role}"); + } + + // Check for active resources + $resourceCount = 0; + foreach ($team->servers()->get() as $server) { + $resources = $server->definedResources(); + $resourceCount += $resources->count(); + } + + if ($resourceCount > 0) { + $this->warn(" ⚠️ This team has {$resourceCount} active resources!"); + } + + // Show subscription details if relevant + if ($team->subscription && $team->subscription->stripe_subscription_id) { + $this->warn(' ⚠️ Active Stripe subscription details:'); + $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}"); + $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}"); + + // Show other owners who could potentially take over + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + $this->info(' Other owners who could take over billing:'); + foreach ($otherOwners as $owner) { + $this->line(" - {$owner->name} ({$owner->email})"); + } + } + } + + $this->newLine(); + } + + $this->error('Please resolve these issues manually before retrying:'); + + // Check if any edge case involves subscription payment issues + $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'Stripe subscription'); + }); + + if ($hasSubscriptionIssue) { + $this->info('For teams with subscription payment issues:'); + $this->info('1. Cancel the subscription through Stripe dashboard, OR'); + $this->info('2. Transfer the subscription to another owner\'s payment method, OR'); + $this->info('3. Have the other owner create a new subscription after cancelling this one'); + $this->newLine(); + } + + $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'No suitable owner replacement'); + }); + + if ($hasNoOwnerReplacement) { + $this->info('For teams with no suitable owner replacement:'); + $this->info('1. Assign an admin role to a trusted member, OR'); + $this->info('2. Transfer team resources to another team, OR'); + $this->info('3. Delete the team manually if no longer needed'); + $this->newLine(); + } + + $this->error('USER DELETION ABORTED DUE TO EDGE CASES'); + $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling"); + + // Return false to trigger proper cleanup and lock release + return false; + } + + if ($preview['to_delete']->isEmpty() && + $preview['to_transfer']->isEmpty() && + $preview['to_leave']->isEmpty()) { + $this->info('No team changes needed.'); + + return true; + } + + if ($preview['to_delete']->isNotEmpty()) { + $this->warn('Teams to be DELETED (user is the only member):'); + $this->table( + ['ID', 'Name', 'Resources', 'Subscription'], + $preview['to_delete']->map(function ($team) { + $resourceCount = 0; + foreach ($team->servers()->get() as $server) { + $resourceCount += $server->definedResources()->count(); + } + $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id + ? '⚠️ YES - '.$team->subscription->stripe_subscription_id + : 'No'; + + return [ + $team->id, + $team->name, + $resourceCount, + $hasSubscription, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_transfer']->isNotEmpty()) { + $this->warn('Teams where ownership will be TRANSFERRED:'); + $this->table( + ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'], + $preview['to_transfer']->map(function ($item) { + return [ + $item['team']->id, + $item['team']->name, + $item['new_owner']->name, + $item['new_owner']->email, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_leave']->isNotEmpty()) { + $this->warn('Teams where user will be REMOVED (other owners/admins exist):'); + $userId = $this->user->id; + $this->table( + ['ID', 'Name', 'User Role', 'Other Members'], + $preview['to_leave']->map(function ($team) use ($userId) { + $userRole = $team->members->where('id', $userId)->first()->pivot->role; + $otherMembers = $team->members->count() - 1; + + return [ + $team->id, + $team->name, + $userRole, + $otherMembers, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ WARNING: Team changes affect access control and ownership!'); + if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Processing team changes...'); + try { + $result = $action->execute(); + $this->info("✓ Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}"); + $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}"); + } catch (\Exception $e) { + $this->error('Failed to process team changes:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function cancelStripeSubscriptions(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 6: CANCEL STRIPE SUBSCRIPTIONS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new CancelSubscription($this->user, $this->isDryRun); + $subscriptions = $action->getSubscriptionsPreview(); + + if ($subscriptions->isEmpty()) { + $this->info('No Stripe subscriptions to cancel.'); + + return true; + } + + // Verify subscriptions in Stripe before showing details + $this->info('Verifying subscriptions in Stripe...'); + $verification = $action->verifySubscriptionsInStripe(); + + if (! empty($verification['errors'])) { + $this->warn('⚠️ Errors occurred during verification:'); + foreach ($verification['errors'] as $error) { + $this->warn(" - {$error}"); + } + $this->newLine(); + } + + if ($verification['not_found']->isNotEmpty()) { + $this->warn('⚠️ Subscriptions not found or inactive in Stripe:'); + foreach ($verification['not_found'] as $item) { + $subscription = $item['subscription']; + $reason = $item['reason']; + $this->line(" - {$subscription->stripe_subscription_id} (Team: {$subscription->team->name}) - {$reason}"); + } + $this->newLine(); + } + + if ($verification['verified']->isEmpty()) { + $this->info('No active subscriptions found in Stripe to cancel.'); + + return true; + } + + $this->info('Active Stripe subscriptions to cancel:'); + $this->newLine(); + + $totalMonthlyValue = 0; + foreach ($verification['verified'] as $item) { + $subscription = $item['subscription']; + $stripeStatus = $item['stripe_status']; + $team = $subscription->team; + $planId = $subscription->stripe_plan_id; + + // Try to get the price from config + $monthlyValue = $this->getSubscriptionMonthlyValue($planId); + $totalMonthlyValue += $monthlyValue; + + $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})"); + $this->line(" Stripe Status: {$stripeStatus}"); + if ($monthlyValue > 0) { + $this->line(" Monthly value: \${$monthlyValue}"); + } + if ($subscription->stripe_cancel_at_period_end) { + $this->line(' ⚠️ Already set to cancel at period end'); + } + } + + if ($totalMonthlyValue > 0) { + $this->newLine(); + $this->warn("Total monthly value: \${$totalMonthlyValue}"); + } + $this->newLine(); + + $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!'); + $this->warn('⚠️ NOTE: This operation happens AFTER database commit and cannot be rolled back!'); + if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Cancelling subscriptions...'); + $result = $action->execute(); + $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed"); + if ($result['failed'] > 0 && ! empty($result['errors'])) { + $this->error('Failed subscriptions:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + + return false; + } + $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}"); + } + + return true; + } + + private function deleteUserProfile(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 5: DELETE USER PROFILE'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!'); + $this->newLine(); + + $this->info('User profile to be deleted:'); + $this->table( + ['Property', 'Value'], + [ + ['Email', $this->user->email], + ['Name', $this->user->name], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'], + ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'], + ] + ); + + $this->newLine(); + + $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:"); + $confirmation = $this->ask('Confirmation'); + + if ($confirmation !== "DELETE {$this->user->email}") { + $this->error('Confirmation text does not match. Deletion cancelled.'); + + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting user profile...'); + + try { + $this->user->delete(); + $this->info('✓ User profile deleted successfully.'); + $this->logAction("User profile deleted: {$this->user->email}"); + } catch (\Exception $e) { + $this->error('Failed to delete user profile:'); + $this->error('Exception: '.get_class($e)); + $this->error('Message: '.$e->getMessage()); + $this->error('File: '.$e->getFile().':'.$e->getLine()); + + if ($this->output->isVerbose()) { + $this->error('Stack Trace:'); + $this->error($e->getTraceAsString()); + } + + $this->logAction("Failed to delete user profile {$this->user->email}: {$e->getMessage()}"); + + throw $e; // Re-throw to trigger rollback + } + } + + return true; + } + + private function getSubscriptionMonthlyValue(string $planId): int + { + // Try to get pricing from subscription metadata or config + // Since we're using dynamic pricing, return 0 for now + // This could be enhanced by fetching the actual price from Stripe API + + // Check if this is a dynamic pricing plan + $dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly'); + $dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly'); + + if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) { + // For dynamic pricing, we can't determine the exact amount without calling Stripe API + // Return 0 to indicate dynamic/usage-based pricing + return 0; + } + + // For any other plans, return 0 as we don't have hardcoded prices + return 0; + } + + private function logAction(string $message): void + { + $logMessage = "[CloudDeleteUser] {$message}"; + + if ($this->isDryRun) { + $logMessage = "[DRY RUN] {$logMessage}"; + } + + Log::channel('single')->info($logMessage); + + // Also log to a dedicated user deletion log file + $logFile = storage_path('logs/user-deletions.log'); + + // Ensure the logs directory exists + $logDir = dirname($logFile); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $timestamp = now()->format('Y-m-d H:i:s'); + file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); + } + + private function displayErrorState(string $failedAt): void + { + $this->newLine(); + $this->error('═══════════════════════════════════════'); + $this->error('DELETION STATE AT FAILURE'); + $this->error('═══════════════════════════════════════'); + $this->error("Failed at: {$failedAt}"); + $this->newLine(); + + $stateTable = []; + foreach ($this->deletionState as $phase => $completed) { + $phaseLabel = str_replace('_', ' ', ucwords($phase, '_')); + $status = $completed ? '✓ Completed' : '✗ Not completed'; + $stateTable[] = [$phaseLabel, $status]; + } + + $this->table(['Phase', 'Status'], $stateTable); + $this->newLine(); + + // Show what was rolled back vs what remains + if ($this->deletionState['db_committed']) { + $this->error('⚠️ DATABASE COMMITTED - Changes CANNOT be rolled back!'); + } else { + $this->info('✓ Database changes were ROLLED BACK'); + } + + $this->newLine(); + $this->error('User email: '.$this->user->email); + $this->error('User ID: '.$this->user->id); + $this->error('Timestamp: '.now()->format('Y-m-d H:i:s')); + $this->newLine(); + } + + private function displayRecoverySteps(): void + { + $this->error('═══════════════════════════════════════'); + $this->error('RECOVERY STEPS'); + $this->error('═══════════════════════════════════════'); + + if (! $this->deletionState['db_committed']) { + $this->info('✓ Database was rolled back - no recovery needed for database'); + $this->newLine(); + + if ($this->deletionState['phase_2_resources'] || $this->deletionState['phase_3_servers']) { + $this->warn('However, some remote operations may have occurred:'); + $this->newLine(); + + if ($this->deletionState['phase_2_resources']) { + $this->warn('Phase 2 (Resources) was attempted:'); + $this->warn('- Check remote servers for orphaned Docker containers'); + $this->warn('- Use: docker ps -a | grep coolify'); + $this->warn('- Manually remove if needed: docker rm -f '); + $this->newLine(); + } + + if ($this->deletionState['phase_3_servers']) { + $this->warn('Phase 3 (Servers) was attempted:'); + $this->warn('- Check for orphaned server configurations'); + $this->warn('- Verify SSH access to servers listed for this user'); + $this->newLine(); + } + } + } else { + $this->error('⚠️ DATABASE WAS COMMITTED - Manual recovery required!'); + $this->newLine(); + $this->error('The following data has been PERMANENTLY deleted:'); + + if ($this->deletionState['phase_5_user_profile']) { + $this->error('- User profile (email: '.$this->user->email.')'); + } + if ($this->deletionState['phase_4_teams']) { + $this->error('- Team memberships and owned teams'); + } + if ($this->deletionState['phase_3_servers']) { + $this->error('- Server records and configurations'); + } + if ($this->deletionState['phase_2_resources']) { + $this->error('- Applications, databases, and services'); + } + + $this->newLine(); + + if (! $this->deletionState['phase_6_stripe']) { + $this->error('Stripe subscriptions were NOT cancelled:'); + $this->error('1. Go to Stripe Dashboard: https://dashboard.stripe.com/'); + $this->error('2. Search for: '.$this->user->email); + $this->error('3. Cancel all active subscriptions manually'); + $this->newLine(); + } + } + + $this->error('Log file: storage/logs/user-deletions.log'); + $this->error('Check logs for detailed error information'); + $this->newLine(); + } + + /** + * Register signal handlers for graceful shutdown on Ctrl+C (SIGINT) and SIGTERM + */ + private function registerSignalHandlers(): void + { + if (! function_exists('pcntl_signal')) { + // pcntl extension not available, skip signal handling + return; + } + + // Handle Ctrl+C (SIGINT) + pcntl_signal(SIGINT, function () { + $this->newLine(); + $this->warn('═══════════════════════════════════════'); + $this->warn('⚠️ PROCESS INTERRUPTED (Ctrl+C)'); + $this->warn('═══════════════════════════════════════'); + $this->info('Cleaning up and releasing lock...'); + $this->releaseLock(); + $this->info('Lock released. Exiting gracefully.'); + exit(130); // Standard exit code for SIGINT + }); + + // Handle SIGTERM + pcntl_signal(SIGTERM, function () { + $this->newLine(); + $this->warn('═══════════════════════════════════════'); + $this->warn('⚠️ PROCESS TERMINATED (SIGTERM)'); + $this->warn('═══════════════════════════════════════'); + $this->info('Cleaning up and releasing lock...'); + $this->releaseLock(); + $this->info('Lock released. Exiting gracefully.'); + exit(143); // Standard exit code for SIGTERM + }); + + // Enable async signal handling + pcntl_async_signals(true); + } + + /** + * Release the lock if it exists + */ + private function releaseLock(): void + { + if ($this->lock) { + try { + $this->lock->release(); + } catch (\Exception $e) { + // Silently ignore lock release errors + // Lock will expire after 10 minutes anyway + } + } + } +} diff --git a/app/Console/Commands/AdminRemoveUser.php b/app/Console/Commands/AdminRemoveUser.php deleted file mode 100644 index d4534399c..000000000 --- a/app/Console/Commands/AdminRemoveUser.php +++ /dev/null @@ -1,56 +0,0 @@ -argument('email'); - $confirm = $this->confirm('Are you sure you want to remove user with email: '.$email.'?'); - if (! $confirm) { - $this->info('User removal cancelled.'); - - return; - } - $this->info("Removing user with email: $email"); - $user = User::whereEmail($email)->firstOrFail(); - $teams = $user->teams; - foreach ($teams as $team) { - if ($team->members->count() > 1) { - $this->error('User is a member of a team with more than one member. Please remove user from team first.'); - - return; - } - $team->delete(); - } - $user->delete(); - } catch (\Exception $e) { - $this->error('Failed to remove user.'); - $this->error($e->getMessage()); - - return; - } - } -} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 2ccb76529..347ea9419 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -64,13 +64,5 @@ public function handle() if ($this->option('yes')) { $scheduled_task_executions->delete(); } - - // Cleanup webhooks table - $webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days)); - $count = $webhooks->count(); - echo "Delete $count entries from webhooks.\n"; - if ($this->option('yes')) { - $webhooks->delete(); - } } } diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php new file mode 100644 index 000000000..2992e32b9 --- /dev/null +++ b/app/Console/Commands/CleanupNames.php @@ -0,0 +1,248 @@ + Project::class, + 'Environment' => Environment::class, + 'Application' => Application::class, + 'Service' => Service::class, + 'Server' => Server::class, + 'Team' => Team::class, + 'StandalonePostgresql' => StandalonePostgresql::class, + 'StandaloneMysql' => StandaloneMysql::class, + 'StandaloneRedis' => StandaloneRedis::class, + 'StandaloneMongodb' => StandaloneMongodb::class, + 'StandaloneMariadb' => StandaloneMariadb::class, + 'StandaloneKeydb' => StandaloneKeydb::class, + 'StandaloneDragonfly' => StandaloneDragonfly::class, + 'StandaloneClickhouse' => StandaloneClickhouse::class, + 'S3Storage' => S3Storage::class, + 'Tag' => Tag::class, + 'PrivateKey' => PrivateKey::class, + 'ScheduledTask' => ScheduledTask::class, + ]; + + protected array $changes = []; + + protected int $totalProcessed = 0; + + protected int $totalCleaned = 0; + + public function handle(): int + { + $this->info('🔍 Scanning for invalid characters in name fields...'); + + if ($this->option('backup') && ! $this->option('dry-run')) { + $this->createBackup(); + } + + $modelFilter = $this->option('model'); + $modelsToProcess = $modelFilter + ? [$modelFilter => $this->modelsToClean[$modelFilter] ?? null] + : $this->modelsToClean; + + if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) { + $this->error("❌ Unknown model: {$modelFilter}"); + $this->info('Available models: '.implode(', ', array_keys($this->modelsToClean))); + + return self::FAILURE; + } + + foreach ($modelsToProcess as $modelName => $modelClass) { + if (! $modelClass) { + continue; + } + $this->processModel($modelName, $modelClass); + } + + $this->displaySummary(); + + if (! $this->option('dry-run') && $this->totalCleaned > 0) { + $this->logChanges(); + } + + return self::SUCCESS; + } + + protected function processModel(string $modelName, string $modelClass): void + { + $this->info("\n📋 Processing {$modelName}..."); + + try { + $records = $modelClass::all(['id', 'name']); + $cleaned = 0; + + foreach ($records as $record) { + $this->totalProcessed++; + + $originalName = $record->name; + $sanitizedName = $this->sanitizeName($originalName); + + if ($sanitizedName !== $originalName) { + $this->changes[] = [ + 'model' => $modelName, + 'id' => $record->id, + 'original' => $originalName, + 'sanitized' => $sanitizedName, + 'timestamp' => now(), + ]; + + if (! $this->option('dry-run')) { + // Update without triggering events/mutators to avoid conflicts + $modelClass::where('id', $record->id)->update(['name' => $sanitizedName]); + } + + $cleaned++; + $this->totalCleaned++; + + $this->warn(" 🧹 {$modelName} #{$record->id}:"); + $this->line(' From: '.$this->truncate($originalName, 80)); + $this->line(' To: '.$this->truncate($sanitizedName, 80)); + } + } + + if ($cleaned > 0) { + $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized'; + $this->info(" ✅ {$cleaned}/{$records->count()} records {$action}"); + } else { + $this->info(' ✨ No invalid characters found'); + } + + } catch (\Exception $e) { + $this->error(" ❌ Error processing {$modelName}: ".$e->getMessage()); + } + } + + protected function sanitizeName(string $name): string + { + // Remove all characters that don't match the allowed pattern + // Use the shared ValidationPatterns to ensure consistency + $allowedPattern = str_replace(['/', '^', '$'], '', ValidationPatterns::NAME_PATTERN); + $sanitized = preg_replace('/[^'.$allowedPattern.']+/', '', $name); + + // Clean up excessive whitespace but preserve other allowed characters + $sanitized = preg_replace('/\s+/', ' ', $sanitized); + $sanitized = trim($sanitized); + + // If result is empty, provide a default name + if (empty($sanitized)) { + $sanitized = 'sanitized-item'; + } + + return $sanitized; + } + + protected function displaySummary(): void + { + $this->info("\n".str_repeat('=', 60)); + $this->info('📊 CLEANUP SUMMARY'); + $this->info(str_repeat('=', 60)); + + $this->line("Records processed: {$this->totalProcessed}"); + $this->line("Records with invalid characters: {$this->totalCleaned}"); + + if ($this->option('dry-run')) { + $this->warn("\n🔍 DRY RUN - No changes were made to the database"); + $this->info('Run without --dry-run to apply these changes'); + } else { + if ($this->totalCleaned > 0) { + $this->info("\n✅ Database successfully sanitized!"); + $this->info('Changes logged to storage/logs/name-cleanup.log'); + } else { + $this->info("\n✨ No cleanup needed - all names are valid!"); + } + } + } + + protected function logChanges(): void + { + $logFile = storage_path('logs/name-cleanup.log'); + $logData = [ + 'timestamp' => now()->toISOString(), + 'total_processed' => $this->totalProcessed, + 'total_cleaned' => $this->totalCleaned, + 'changes' => $this->changes, + ]; + + file_put_contents($logFile, json_encode($logData, JSON_PRETTY_PRINT)."\n", FILE_APPEND); + + Log::info('Name Sanitization completed', [ + 'total_processed' => $this->totalProcessed, + 'total_sanitized' => $this->totalCleaned, + 'changes_count' => count($this->changes), + ]); + } + + protected function createBackup(): void + { + $this->info('💾 Creating database backup...'); + + try { + $backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql'); + + // Ensure backup directory exists + if (! file_exists(dirname($backupFile))) { + mkdir(dirname($backupFile), 0755, true); + } + + $dbConfig = config('database.connections.'.config('database.default')); + $command = sprintf( + 'pg_dump -h %s -p %s -U %s -d %s > %s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['username'], + $dbConfig['database'], + $backupFile + ); + + exec($command, $output, $returnCode); + + if ($returnCode === 0) { + $this->info("✅ Backup created: {$backupFile}"); + } else { + $this->warn('⚠️ Backup creation may have failed. Proceeding anyway...'); + } + } catch (\Exception $e) { + $this->warn('⚠️ Could not create backup: '.$e->getMessage()); + $this->warn('Proceeding without backup...'); + } + } + + protected function truncate(string $text, int $length): string + { + return strlen($text) > $length ? substr($text, 0, $length).'...' : $text; + } +} diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index 315d1adc7..a13cda0b8 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -7,26 +7,270 @@ class CleanupRedis extends Command { - protected $signature = 'cleanup:redis'; + protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}'; - protected $description = 'Cleanup Redis'; + protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)'; public function handle() { $redis = Redis::connection('horizon'); - $keys = $redis->keys('*'); $prefix = config('horizon.prefix'); + $dryRun = $this->option('dry-run'); + $skipOverlapping = $this->option('skip-overlapping'); + + if ($dryRun) { + $this->info('DRY RUN MODE - No data will be deleted'); + } + + $deletedCount = 0; + $totalKeys = 0; + + // Get all keys with the horizon prefix + $keys = $redis->keys('*'); + $totalKeys = count($keys); + + $this->info("Scanning {$totalKeys} keys for cleanup..."); + foreach ($keys as $key) { $keyWithoutPrefix = str_replace($prefix, '', $key); $type = $redis->command('type', [$keyWithoutPrefix]); + // Handle hash-type keys (individual jobs) if ($type === 5) { - $data = $redis->command('hgetall', [$keyWithoutPrefix]); - $status = data_get($data, 'status'); - if ($status === 'completed') { - $redis->command('del', [$keyWithoutPrefix]); + if ($this->shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)) { + $deletedCount++; + } + } + // Handle other key types (metrics, lists, etc.) + else { + if ($this->shouldDeleteOtherKey($redis, $keyWithoutPrefix, $key, $dryRun)) { + $deletedCount++; } } } + + // Clean up overlapping queues if not skipped + if (! $skipOverlapping) { + $this->info('Cleaning up overlapping queues...'); + $overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun); + $deletedCount += $overlappingCleaned; + } + + if ($dryRun) { + $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys"); + } else { + $this->info("Deleted {$deletedCount} out of {$totalKeys} keys"); + } + } + + private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun) + { + $data = $redis->command('hgetall', [$keyWithoutPrefix]); + $status = data_get($data, 'status'); + + // Delete completed and failed jobs + if (in_array($status, ['completed', 'failed'])) { + if ($dryRun) { + $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})"); + } else { + $redis->command('del', [$keyWithoutPrefix]); + $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})"); + } + + return true; + } + + return false; + } + + private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryRun) + { + // Clean up various Horizon data structures + $patterns = [ + 'recent_jobs' => 'Recent jobs list', + 'failed_jobs' => 'Failed jobs list', + 'completed_jobs' => 'Completed jobs list', + 'job_classes' => 'Job classes metrics', + 'queues' => 'Queue metrics', + 'processes' => 'Process metrics', + 'supervisors' => 'Supervisor data', + 'metrics' => 'General metrics', + 'workload' => 'Workload data', + ]; + + foreach ($patterns as $pattern => $description) { + if (str_contains($keyWithoutPrefix, $pattern)) { + if ($dryRun) { + $this->line("Would delete {$description}: {$keyWithoutPrefix}"); + } else { + $redis->command('del', [$keyWithoutPrefix]); + $this->line("Deleted {$description}: {$keyWithoutPrefix}"); + } + + return true; + } + } + + // Clean up old timestamped data (older than 7 days) + if (preg_match('/(\d{10})/', $keyWithoutPrefix, $matches)) { + $timestamp = (int) $matches[1]; + $weekAgo = now()->subDays(7)->timestamp; + + if ($timestamp < $weekAgo) { + if ($dryRun) { + $this->line("Would delete old timestamped data: {$keyWithoutPrefix}"); + } else { + $redis->command('del', [$keyWithoutPrefix]); + $this->line("Deleted old timestamped data: {$keyWithoutPrefix}"); + } + + return true; + } + } + + return false; + } + + private function cleanupOverlappingQueues($redis, $prefix, $dryRun) + { + $cleanedCount = 0; + $queueKeys = []; + + // Find all queue-related keys + $allKeys = $redis->keys('*'); + foreach ($allKeys as $key) { + $keyWithoutPrefix = str_replace($prefix, '', $key); + if (str_contains($keyWithoutPrefix, 'queue:') || preg_match('/queues?[:\-]/', $keyWithoutPrefix)) { + $queueKeys[] = $keyWithoutPrefix; + } + } + + $this->info('Found '.count($queueKeys).' queue-related keys'); + + // Group queues by name pattern to find duplicates + $queueGroups = []; + foreach ($queueKeys as $queueKey) { + // Extract queue name (remove timestamps, suffixes) + $baseName = preg_replace('/[:\-]\d+$/', '', $queueKey); + $baseName = preg_replace('/[:\-](pending|reserved|delayed|processing)$/', '', $baseName); + + if (! isset($queueGroups[$baseName])) { + $queueGroups[$baseName] = []; + } + $queueGroups[$baseName][] = $queueKey; + } + + // Process each group for overlaps + foreach ($queueGroups as $baseName => $keys) { + if (count($keys) > 1) { + $cleanedCount += $this->deduplicateQueueGroup($redis, $baseName, $keys, $dryRun); + } + + // Also check for duplicate jobs within individual queues + foreach ($keys as $queueKey) { + $cleanedCount += $this->deduplicateQueueContents($redis, $queueKey, $dryRun); + } + } + + return $cleanedCount; + } + + private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun) + { + $cleanedCount = 0; + $this->line("Processing queue group: {$baseName} (".count($keys).' keys)'); + + // Sort keys to keep the most recent one + usort($keys, function ($a, $b) { + // Prefer keys without timestamps (they're usually the main queue) + $aHasTimestamp = preg_match('/\d{10}/', $a); + $bHasTimestamp = preg_match('/\d{10}/', $b); + + if ($aHasTimestamp && ! $bHasTimestamp) { + return 1; + } + if (! $aHasTimestamp && $bHasTimestamp) { + return -1; + } + + // If both have timestamps, prefer the newer one + if ($aHasTimestamp && $bHasTimestamp) { + preg_match('/(\d{10})/', $a, $aMatches); + preg_match('/(\d{10})/', $b, $bMatches); + + return ($bMatches[1] ?? 0) <=> ($aMatches[1] ?? 0); + } + + return strcmp($a, $b); + }); + + // Keep the first (preferred) key, remove others that are empty or redundant + $keepKey = array_shift($keys); + + foreach ($keys as $redundantKey) { + $type = $redis->command('type', [$redundantKey]); + $shouldDelete = false; + + if ($type === 1) { // LIST type + $length = $redis->command('llen', [$redundantKey]); + if ($length == 0) { + $shouldDelete = true; + } + } elseif ($type === 3) { // SET type + $count = $redis->command('scard', [$redundantKey]); + if ($count == 0) { + $shouldDelete = true; + } + } elseif ($type === 4) { // ZSET type + $count = $redis->command('zcard', [$redundantKey]); + if ($count == 0) { + $shouldDelete = true; + } + } + + if ($shouldDelete) { + if ($dryRun) { + $this->line(" Would delete empty queue: {$redundantKey}"); + } else { + $redis->command('del', [$redundantKey]); + $this->line(" Deleted empty queue: {$redundantKey}"); + } + $cleanedCount++; + } + } + + return $cleanedCount; + } + + private function deduplicateQueueContents($redis, $queueKey, $dryRun) + { + $cleanedCount = 0; + $type = $redis->command('type', [$queueKey]); + + if ($type === 1) { // LIST type - common for job queues + $length = $redis->command('llen', [$queueKey]); + if ($length > 1) { + $items = $redis->command('lrange', [$queueKey, 0, -1]); + $uniqueItems = array_unique($items); + + if (count($uniqueItems) < count($items)) { + $duplicates = count($items) - count($uniqueItems); + + if ($dryRun) { + $this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}"); + } else { + // Rebuild the list with unique items + $redis->command('del', [$queueKey]); + foreach (array_reverse($uniqueItems) as $item) { + $redis->command('lpush', [$queueKey, $item]); + } + $this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}"); + } + $cleanedCount += $duplicates; + } + } + } + + return $cleanedCount; } } diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 0b5eef84d..0b13462ef 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Jobs\CleanupHelperContainersJob; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -12,6 +13,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\SslCertificate; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -20,6 +22,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\Team; use Illuminate\Console\Command; class CleanupStuckedResources extends Command @@ -36,6 +39,12 @@ public function handle() private function cleanup_stucked_resources() { try { + $teams = Team::all()->filter(function ($team) { + return $team->members()->count() === 0 && $team->servers()->count() === 0; + }); + foreach ($teams as $team) { + $team->delete(); + } $servers = Server::all()->filter(function ($server) { return $server->isFunctional(); }); @@ -50,6 +59,15 @@ private function cleanup_stucked_resources() } catch (\Throwable $e) { echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; } + try { + $servers = Server::onlyTrashed()->get(); + foreach ($servers as $server) { + echo "Force deleting stuck server: {$server->name}\n"; + $server->forceDelete(); + } + } catch (\Throwable $e) { + echo "Error in cleaning stuck servers: {$e->getMessage()}\n"; + } try { $applicationsDeploymentQueue = ApplicationDeploymentQueue::get(); foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) { @@ -65,7 +83,7 @@ private function cleanup_stucked_resources() $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($applications as $application) { echo "Deleting stuck application: {$application->name}\n"; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; @@ -75,26 +93,35 @@ private function cleanup_stucked_resources() foreach ($applicationsPreviews as $applicationPreview) { if (! data_get($applicationPreview, 'application')) { echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; - $applicationPreview->delete(); + DeleteResourceJob::dispatch($applicationPreview); } } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } + try { + $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get(); + foreach ($applicationsPreviews as $applicationPreview) { + echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n"; + DeleteResourceJob::dispatch($applicationPreview); + } + } catch (\Throwable $e) { + echo "Error in cleaning stuck application: {$e->getMessage()}\n"; + } try { $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($postgresqls as $postgresql) { echo "Deleting stuck postgresql: {$postgresql->name}\n"; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); } } catch (\Throwable $e) { echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { - $redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); - foreach ($redis as $redis) { + $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); + foreach ($rediss as $redis) { echo "Deleting stuck redis: {$redis->name}\n"; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); } } catch (\Throwable $e) { echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; @@ -103,7 +130,7 @@ private function cleanup_stucked_resources() $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($keydbs as $keydb) { echo "Deleting stuck keydb: {$keydb->name}\n"; - $keydb->forceDelete(); + DeleteResourceJob::dispatch($keydb); } } catch (\Throwable $e) { echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; @@ -112,7 +139,7 @@ private function cleanup_stucked_resources() $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($dragonflies as $dragonfly) { echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; - $dragonfly->forceDelete(); + DeleteResourceJob::dispatch($dragonfly); } } catch (\Throwable $e) { echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; @@ -121,7 +148,7 @@ private function cleanup_stucked_resources() $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($clickhouses as $clickhouse) { echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; - $clickhouse->forceDelete(); + DeleteResourceJob::dispatch($clickhouse); } } catch (\Throwable $e) { echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; @@ -130,7 +157,7 @@ private function cleanup_stucked_resources() $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mongodbs as $mongodb) { echo "Deleting stuck mongodb: {$mongodb->name}\n"; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); } } catch (\Throwable $e) { echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; @@ -139,7 +166,7 @@ private function cleanup_stucked_resources() $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mysqls as $mysql) { echo "Deleting stuck mysql: {$mysql->name}\n"; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); } } catch (\Throwable $e) { echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; @@ -148,7 +175,7 @@ private function cleanup_stucked_resources() $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mariadbs as $mariadb) { echo "Deleting stuck mariadb: {$mariadb->name}\n"; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); } } catch (\Throwable $e) { echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; @@ -157,7 +184,7 @@ private function cleanup_stucked_resources() $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($services as $service) { echo "Deleting stuck service: {$service->name}\n"; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); } } catch (\Throwable $e) { echo "Error in cleaning stuck service: {$e->getMessage()}\n"; @@ -210,19 +237,19 @@ private function cleanup_stucked_resources() foreach ($applications as $application) { if (! data_get($application, 'environment')) { echo 'Application without environment: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } if (! $application->destination()) { echo 'Application without destination: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } if (! data_get($application, 'destination.server')) { echo 'Application without server: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } @@ -235,19 +262,19 @@ private function cleanup_stucked_resources() foreach ($postgresqls as $postgresql) { if (! data_get($postgresql, 'environment')) { echo 'Postgresql without environment: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } if (! $postgresql->destination()) { echo 'Postgresql without destination: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } if (! data_get($postgresql, 'destination.server')) { echo 'Postgresql without server: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } @@ -260,19 +287,19 @@ private function cleanup_stucked_resources() foreach ($redis as $redis) { if (! data_get($redis, 'environment')) { echo 'Redis without environment: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } if (! $redis->destination()) { echo 'Redis without destination: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } if (! data_get($redis, 'destination.server')) { echo 'Redis without server: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } @@ -286,19 +313,19 @@ private function cleanup_stucked_resources() foreach ($mongodbs as $mongodb) { if (! data_get($mongodb, 'environment')) { echo 'Mongodb without environment: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } if (! $mongodb->destination()) { echo 'Mongodb without destination: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } if (! data_get($mongodb, 'destination.server')) { echo 'Mongodb without server: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } @@ -312,19 +339,19 @@ private function cleanup_stucked_resources() foreach ($mysqls as $mysql) { if (! data_get($mysql, 'environment')) { echo 'Mysql without environment: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } if (! $mysql->destination()) { echo 'Mysql without destination: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } if (! data_get($mysql, 'destination.server')) { echo 'Mysql without server: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } @@ -338,19 +365,19 @@ private function cleanup_stucked_resources() foreach ($mariadbs as $mariadb) { if (! data_get($mariadb, 'environment')) { echo 'Mariadb without environment: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } if (! $mariadb->destination()) { echo 'Mariadb without destination: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } if (! data_get($mariadb, 'destination.server')) { echo 'Mariadb without server: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } @@ -364,19 +391,19 @@ private function cleanup_stucked_resources() foreach ($services as $service) { if (! data_get($service, 'environment')) { echo 'Service without environment: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } if (! $service->destination()) { echo 'Service without destination: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } if (! data_get($service, 'server')) { echo 'Service without server: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -389,7 +416,7 @@ private function cleanup_stucked_resources() foreach ($serviceApplications as $service) { if (! data_get($service, 'service')) { echo 'ServiceApplication without service: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -402,7 +429,7 @@ private function cleanup_stucked_resources() foreach ($serviceDatabases as $service) { if (! data_get($service, 'service')) { echo 'ServiceDatabase without service: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -410,5 +437,18 @@ private function cleanup_stucked_resources() } catch (\Throwable $e) { echo "Error in ServiceDatabases: {$e->getMessage()}\n"; } + + try { + $orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) { + $query->select('id')->from('servers'); + })->get(); + + foreach ($orphanedCerts as $cert) { + echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n"; + $cert->delete(); + } + } catch (\Throwable $e) { + echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n"; + } } } diff --git a/app/Console/Commands/ClearGlobalSearchCache.php b/app/Console/Commands/ClearGlobalSearchCache.php new file mode 100644 index 000000000..a368b0bad --- /dev/null +++ b/app/Console/Commands/ClearGlobalSearchCache.php @@ -0,0 +1,83 @@ +option('all')) { + return $this->clearAllTeamsCache(); + } + + if ($teamId = $this->option('team')) { + return $this->clearTeamCache($teamId); + } + + // If no options provided, clear cache for current user's team + if (! auth()->check()) { + $this->error('No authenticated user found. Use --team=ID or --all option.'); + + return Command::FAILURE; + } + + $teamId = auth()->user()->currentTeam()->id; + + return $this->clearTeamCache($teamId); + } + + private function clearTeamCache(int $teamId): int + { + $team = Team::find($teamId); + + if (! $team) { + $this->error("Team with ID {$teamId} not found."); + + return Command::FAILURE; + } + + GlobalSearch::clearTeamCache($teamId); + $this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})"); + + return Command::SUCCESS; + } + + private function clearAllTeamsCache(): int + { + $teams = Team::all(); + + if ($teams->isEmpty()) { + $this->warn('No teams found.'); + + return Command::SUCCESS; + } + + $count = 0; + foreach ($teams as $team) { + GlobalSearch::clearTeamCache($team->id); + $count++; + } + + $this->info("✓ Cleared global search cache for {$count} team(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Cloud/CloudFixSubscription.php b/app/Console/Commands/Cloud/CloudFixSubscription.php new file mode 100644 index 000000000..194e9bb5f --- /dev/null +++ b/app/Console/Commands/Cloud/CloudFixSubscription.php @@ -0,0 +1,879 @@ +option('verify-all')) { + return $this->verifyAllActiveSubscriptions($stripe); + } + + if ($this->option('fix-canceled-subs') || $this->option('dry-run')) { + return $this->fixCanceledSubscriptions($stripe); + } + + $activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get(); + + $out = fopen('php://output', 'w'); + // CSV header + fputcsv($out, [ + 'team_id', + 'invoice_status', + 'stripe_customer_url', + 'stripe_subscription_id', + 'subscription_status', + 'subscription_url', + 'note', + ]); + + foreach ($activeSubscribers as $team) { + $stripeSubscriptionId = $team->subscription->stripe_subscription_id; + $stripeInvoicePaid = $team->subscription->stripe_invoice_paid; + $stripeCustomerId = $team->subscription->stripe_customer_id; + + if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') { + fputcsv($out, [ + $team->id, + $stripeInvoicePaid, + $stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null, + null, + null, + null, + 'Missing subscription ID while invoice not past_due', + ]); + + continue; + } + + if (! $stripeSubscriptionId) { + // No subscription ID and invoice is past_due, still record for visibility + fputcsv($out, [ + $team->id, + $stripeInvoicePaid, + $stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null, + null, + null, + null, + 'Missing subscription ID', + ]); + + continue; + } + + $subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId); + if ($subscription->status === 'active') { + continue; + } + + fputcsv($out, [ + $team->id, + $stripeInvoicePaid, + $stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null, + $stripeSubscriptionId, + $subscription->status, + "https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}", + 'Subscription not active', + ]); + } + + fclose($out); + } + + /** + * Fix canceled subscriptions in the database + */ + private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) + { + $isDryRun = $this->option('dry-run'); + $checkOne = $this->option('one'); + + if ($isDryRun) { + $this->info('DRY RUN MODE - No changes will be made'); + if ($checkOne) { + $this->info('Checking only the first canceled subscription...'); + } else { + $this->info('Checking for canceled subscriptions...'); + } + } else { + if ($checkOne) { + $this->info('Checking and fixing only the first canceled subscription...'); + } else { + $this->info('Checking and fixing canceled subscriptions...'); + } + } + + $teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get(); + $toFixCount = 0; + $fixedCount = 0; + $errors = []; + $canceledSubscriptions = []; + + foreach ($teamsWithSubscriptions as $team) { + $subscription = $team->subscription; + + if (! $subscription->stripe_subscription_id) { + continue; + } + + try { + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + if ($stripeSubscription->status === 'canceled') { + $toFixCount++; + + // Get team members' emails + $memberEmails = $team->members->pluck('email')->toArray(); + + $canceledSubscriptions[] = [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'customer_id' => $subscription->stripe_customer_id, + 'subscription_id' => $subscription->stripe_subscription_id, + 'status' => 'canceled', + 'member_emails' => $memberEmails, + 'subscription_model' => $subscription->toArray(), + ]; + + if ($isDryRun) { + $this->warn('Would fix canceled subscription:'); + $this->line(" Team ID: {$team->id}"); + $this->line(" Team Name: {$team->name}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + $this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}"); + $this->line(' Current Subscription Data:'); + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $this->line(" - {$key}: null"); + } elseif (is_bool($value)) { + $this->line(" - {$key}: ".($value ? 'true' : 'false')); + } else { + $this->line(" - {$key}: {$value}"); + } + } + $this->newLine(); + } else { + $this->warn("Found canceled subscription for Team ID: {$team->id}"); + + // Send internal notification with all details before fixing + $notificationMessage = "Fixing canceled subscription:\n"; + $notificationMessage .= "Team ID: {$team->id}\n"; + $notificationMessage .= "Team Name: {$team->name}\n"; + $notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n"; + $notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n"; + $notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n"; + $notificationMessage .= "Subscription Data:\n"; + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $notificationMessage .= " - {$key}: null\n"; + } elseif (is_bool($value)) { + $notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n"; + } else { + $notificationMessage .= " - {$key}: {$value}\n"; + } + } + send_internal_notification($notificationMessage); + + // Apply the same logic as customer.subscription.deleted webhook + $team->subscriptionEnded(); + + $fixedCount++; + $this->info(" ✓ Fixed subscription for Team ID: {$team->id}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + $this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}"); + } + + // Break if --one flag is set + if ($checkOne) { + break; + } + } + } catch (\Stripe\Exception\InvalidRequestException $e) { + if ($e->getStripeCode() === 'resource_missing') { + $toFixCount++; + + // Get team members' emails + $memberEmails = $team->members->pluck('email')->toArray(); + + $canceledSubscriptions[] = [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'customer_id' => $subscription->stripe_customer_id, + 'subscription_id' => $subscription->stripe_subscription_id, + 'status' => 'missing', + 'member_emails' => $memberEmails, + 'subscription_model' => $subscription->toArray(), + ]; + + if ($isDryRun) { + $this->error('Would fix missing subscription (not found in Stripe):'); + $this->line(" Team ID: {$team->id}"); + $this->line(" Team Name: {$team->name}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + $this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}"); + $this->line(' Current Subscription Data:'); + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $this->line(" - {$key}: null"); + } elseif (is_bool($value)) { + $this->line(" - {$key}: ".($value ? 'true' : 'false')); + } else { + $this->line(" - {$key}: {$value}"); + } + } + $this->newLine(); + } else { + $this->error("Subscription not found in Stripe for Team ID: {$team->id}"); + + // Send internal notification with all details before fixing + $notificationMessage = "Fixing missing subscription (not found in Stripe):\n"; + $notificationMessage .= "Team ID: {$team->id}\n"; + $notificationMessage .= "Team Name: {$team->name}\n"; + $notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n"; + $notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n"; + $notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n"; + $notificationMessage .= "Subscription Data:\n"; + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $notificationMessage .= " - {$key}: null\n"; + } elseif (is_bool($value)) { + $notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n"; + } else { + $notificationMessage .= " - {$key}: {$value}\n"; + } + } + send_internal_notification($notificationMessage); + + // Apply the same logic as customer.subscription.deleted webhook + $team->subscriptionEnded(); + + $fixedCount++; + $this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + } + + // Break if --one flag is set + if ($checkOne) { + break; + } + } else { + $errors[] = "Team ID {$team->id}: ".$e->getMessage(); + } + } catch (\Exception $e) { + $errors[] = "Team ID {$team->id}: ".$e->getMessage(); + } + } + + $this->newLine(); + $this->info('Summary:'); + + if ($isDryRun) { + $this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed"); + + if ($toFixCount > 0) { + $this->newLine(); + $this->comment('Run with --fix-canceled-subs to apply these changes'); + } + } else { + $this->info(" - Fixed {$fixedCount} canceled/missing subscriptions"); + } + + if (! empty($errors)) { + $this->newLine(); + $this->error('Errors encountered:'); + foreach ($errors as $error) { + $this->error(" - {$error}"); + } + } + + return 0; + } + + /** + * Verify all active subscriptions against Stripe API + */ + private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) + { + $isDryRun = $this->option('dry-run'); + $shouldFix = $this->option('fix-verified'); + + $this->info('Verifying all active subscriptions against Stripe...'); + if ($isDryRun) { + $this->info('DRY RUN MODE - No changes will be made'); + } + if ($shouldFix && ! $isDryRun) { + $this->warn('FIX MODE - Discrepancies will be corrected'); + } + + // Get all teams with active subscriptions + $teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get(); + $totalCount = $teamsWithActiveSubscriptions->count(); + + $this->info("Found {$totalCount} teams with active subscriptions in database"); + $this->newLine(); + + $out = fopen('php://output', 'w'); + + // CSV header + fputcsv($out, [ + 'team_id', + 'team_name', + 'customer_id', + 'subscription_id', + 'db_status', + 'stripe_status', + 'action', + 'member_emails', + 'customer_url', + 'subscription_url', + ]); + + $stats = [ + 'total' => $totalCount, + 'valid_active' => 0, + 'valid_past_due' => 0, + 'canceled' => 0, + 'missing' => 0, + 'invalid' => 0, + 'fixed' => 0, + 'errors' => 0, + ]; + + $processedCount = 0; + + foreach ($teamsWithActiveSubscriptions as $team) { + $subscription = $team->subscription; + $memberEmails = $team->members->pluck('email')->toArray(); + + // Database state + $dbStatus = 'active'; + if ($subscription->stripe_past_due) { + $dbStatus = 'past_due'; + } + + $stripeStatus = null; + $action = 'none'; + + if (! $subscription->stripe_subscription_id) { + $this->line("Team {$team->id}: Missing subscription ID, searching in Stripe..."); + + $foundResult = null; + $searchMethod = null; + + // Search by customer ID + if ($subscription->stripe_customer_id) { + $this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}"); + $foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id); + if ($foundResult) { + $searchMethod = $foundResult['method']; + } + } else { + $this->line(' → No customer ID available'); + } + + // Search by emails if not found + if (! $foundResult && count($memberEmails) > 0) { + $foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails); + if ($foundResult) { + $searchMethod = $foundResult['method']; + + // Update customer ID if different + if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) { + if ($isDryRun) { + $this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}"); + } elseif ($shouldFix) { + $subscription->update(['stripe_customer_id' => $foundResult['customer_id']]); + $this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}"); + } + } + } + } + + if ($foundResult && isset($foundResult['subscription'])) { + // Check if it's an active/past_due subscription + if (in_array($foundResult['status'], ['active', 'past_due'])) { + // Found an active subscription, handle update + $result = $this->handleFoundSubscription( + $team, + $subscription, + $foundResult['subscription'], + $searchMethod, + $isDryRun, + $shouldFix, + $stats + ); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $result['id'], + $dbStatus, + $result['status'], + $result['action'], + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $result['url'], + ]); + } else { + // Found subscription but it's canceled/expired - needs to be deactivated + $this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation"); + + $result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $foundResult['subscription']->id, + $dbStatus, + $foundResult['status'], + 'needs_fix', + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}", + ]); + } + } else { + // No subscription found at all + $this->line(' → No subscription found'); + + $stripeStatus = 'not_found'; + $result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + 'N/A', + $dbStatus, + $result['status'], + $result['action'], + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + 'N/A', + ]); + } + } else { + // First validate the subscription ID format + if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) { + $this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')"); + } + + try { + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + $stripeStatus = $stripeSubscription->status; + + // Determine if action is needed + switch ($stripeStatus) { + case 'active': + $stats['valid_active']++; + $action = 'valid'; + break; + + case 'past_due': + $stats['valid_past_due']++; + $action = 'valid'; + // Ensure past_due flag is set + if (! $subscription->stripe_past_due) { + if ($isDryRun) { + $this->info("Would set stripe_past_due=true for Team {$team->id}"); + } elseif ($shouldFix) { + $subscription->update(['stripe_past_due' => true]); + } + } + break; + + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + case 'incomplete': + $stats['canceled']++; + $action = 'needs_fix'; + + // Only output problematic subscriptions + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + $stripeStatus, + $action, + implode(', ', $memberEmails), + "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}", + "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}", + ]); + + if ($isDryRun) { + $this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}"); + } elseif ($shouldFix) { + $this->fixSubscription($team, $subscription, $stripeStatus); + $stats['fixed']++; + } + break; + + default: + $stats['invalid']++; + $action = 'unknown'; + + // Only output problematic subscriptions + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + $stripeStatus, + $action, + implode(', ', $memberEmails), + "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}", + "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}", + ]); + break; + } + + } catch (\Stripe\Exception\InvalidRequestException $e) { + $this->error(' → Error: '.$e->getMessage()); + + if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) { + // Subscription doesn't exist, try to find by customer ID + $this->warn(" → Subscription not found, checking customer's subscriptions..."); + + $foundResult = null; + if ($subscription->stripe_customer_id) { + $foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id); + } + + if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) { + // Found an active subscription with different ID + $this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}"); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + "WRONG ID: {$subscription->stripe_subscription_id} → {$foundResult['subscription']->id}", + $dbStatus, + $foundResult['status'], + 'id_mismatch', + implode(', ', $memberEmails), + "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}", + "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}", + ]); + + if ($isDryRun) { + $this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}"); + } elseif ($shouldFix) { + $subscription->update([ + 'stripe_subscription_id' => $foundResult['subscription']->id, + 'stripe_invoice_paid' => true, + 'stripe_past_due' => $foundResult['status'] === 'past_due', + ]); + $stats['fixed']++; + $this->info(' → Updated subscription ID'); + } + + $stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++; + } else { + // No active subscription found + $stripeStatus = $foundResult ? $foundResult['status'] : 'not_found'; + $result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + $result['status'], + $result['action'], + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A', + ]); + } + } else { + // Other API error + $stats['errors']++; + $this->error(' → API Error - not marking as deleted'); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + 'error: '.$e->getStripeCode(), + 'error', + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A', + ]); + } + } catch (\Exception $e) { + $this->error(' → Unexpected error: '.$e->getMessage()); + $stats['errors']++; + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + 'error', + 'error', + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A', + ]); + } + } + + $processedCount++; + if ($processedCount % 100 === 0) { + $this->info("Processed {$processedCount}/{$totalCount} subscriptions..."); + } + } + + fclose($out); + + // Print summary + $this->newLine(2); + $this->info('=== Verification Summary ==='); + $this->info("Total subscriptions checked: {$stats['total']}"); + $this->newLine(); + + $this->info('Valid subscriptions in Stripe:'); + $this->line(" - Active: {$stats['valid_active']}"); + $this->line(" - Past Due: {$stats['valid_past_due']}"); + $validTotal = $stats['valid_active'] + $stats['valid_past_due']; + $this->info(" Total valid: {$validTotal}"); + + $this->newLine(); + $this->warn('Invalid subscriptions:'); + $this->line(" - Canceled/Expired: {$stats['canceled']}"); + $this->line(" - Missing/Not Found: {$stats['missing']}"); + $this->line(" - Unknown status: {$stats['invalid']}"); + $invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid']; + $this->warn(" Total invalid: {$invalidTotal}"); + + if ($stats['errors'] > 0) { + $this->newLine(); + $this->error("Errors encountered: {$stats['errors']}"); + } + + if ($shouldFix && ! $isDryRun) { + $this->newLine(); + $this->info("Fixed subscriptions: {$stats['fixed']}"); + } elseif ($invalidTotal > 0 && ! $shouldFix) { + $this->newLine(); + $this->comment('Run with --fix-verified to fix the discrepancies'); + } + + return 0; + } + + /** + * Fix a subscription based on its status + */ + private function fixSubscription($team, $subscription, $status) + { + $message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n"; + $message .= "Team Name: {$team->name}\n"; + $message .= "Customer ID: {$subscription->stripe_customer_id}\n"; + $message .= "Subscription ID: {$subscription->stripe_subscription_id}\n"; + + send_internal_notification($message); + + // Call the team's subscription ended method which properly cleans up + $team->subscriptionEnded(); + } + + /** + * Search for subscriptions by customer ID + */ + private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false) + { + try { + $subscriptions = $stripe->subscriptions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'all', + ]); + + $this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer'); + + // Look for active/past_due first + foreach ($subscriptions->data as $sub) { + $this->line(" - Subscription {$sub->id}: status={$sub->status}"); + if (in_array($sub->status, ['active', 'past_due'])) { + $this->info(" ✓ Found active/past_due subscription: {$sub->id}"); + + return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id']; + } + } + + // If not requiring active and there are subscriptions, return first one + if (! $requireActive && count($subscriptions->data) > 0) { + $sub = $subscriptions->data[0]; + $this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}"); + + return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first']; + } + + return null; + } catch (\Exception $e) { + $this->error(' → Error searching by customer ID: '.$e->getMessage()); + + return null; + } + } + + /** + * Search for subscriptions by team member emails + */ + private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails) + { + $this->line(' → Searching by team member emails...'); + + foreach ($emails as $email) { + $this->line(" → Checking email: {$email}"); + + try { + $customers = $stripe->customers->all([ + 'email' => $email, + 'limit' => 5, + ]); + + if (count($customers->data) === 0) { + $this->line(' - No customers found'); + + continue; + } + + $this->line(' - Found '.count($customers->data).' customer(s)'); + + foreach ($customers->data as $customer) { + $this->line(" - Checking customer {$customer->id}"); + + $result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true); + if ($result) { + $result['method'] = "email:{$email}"; + $result['customer_id'] = $customer->id; + + return $result; + } + } + } catch (\Exception $e) { + $this->error(" - Error searching for email {$email}: ".$e->getMessage()); + } + } + + return null; + } + + /** + * Handle found subscription update (only for active/past_due subscriptions) + */ + private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats) + { + $stripeStatus = $foundSub->status; + $this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})"); + + // Only update if it's active or past_due + if (! in_array($stripeStatus, ['active', 'past_due'])) { + $this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!"); + + return [ + 'id' => $foundSub->id, + 'status' => $stripeStatus, + 'action' => 'error', + 'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}", + ]; + } + + if ($isDryRun) { + $this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})"); + } elseif ($shouldFix) { + $subscription->update([ + 'stripe_subscription_id' => $foundSub->id, + 'stripe_invoice_paid' => true, + 'stripe_past_due' => $stripeStatus === 'past_due', + ]); + $stats['fixed']++; + $this->info(" → Updated subscription ID to {$foundSub->id}"); + } + + // Update stats + $stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++; + + return [ + 'id' => "FOUND: {$foundSub->id}", + 'status' => $stripeStatus, + 'action' => "will_update (via {$searchMethod})", + 'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}", + ]; + } + + /** + * Handle missing subscription + */ + private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats) + { + $stats['missing']++; + + if ($isDryRun) { + $statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe'; + $this->warn(" → Would deactivate subscription - {$statusMsg}"); + } elseif ($shouldFix) { + $this->fixSubscription($team, $subscription, $status); + $stats['fixed']++; + $this->info(' → Deactivated subscription'); + } + + return [ + 'id' => 'N/A', + 'status' => $status, + 'action' => 'needs_fix', + 'url' => 'N/A', + ]; + } +} diff --git a/app/Console/Commands/CloudCheckSubscription.php b/app/Console/Commands/CloudCheckSubscription.php deleted file mode 100644 index 6e237e84b..000000000 --- a/app/Console/Commands/CloudCheckSubscription.php +++ /dev/null @@ -1,49 +0,0 @@ -get(); - foreach ($activeSubscribers as $team) { - $stripeSubscriptionId = $team->subscription->stripe_subscription_id; - $stripeInvoicePaid = $team->subscription->stripe_invoice_paid; - $stripeCustomerId = $team->subscription->stripe_customer_id; - if (! $stripeSubscriptionId) { - echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n"; - echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n"; - - continue; - } - $subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId); - if ($subscription->status === 'active') { - continue; - } - echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n"; - echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n"; - } - } -} diff --git a/app/Console/Commands/CloudCleanupSubscriptions.php b/app/Console/Commands/CloudCleanupSubscriptions.php deleted file mode 100644 index ab676c927..000000000 --- a/app/Console/Commands/CloudCleanupSubscriptions.php +++ /dev/null @@ -1,101 +0,0 @@ -error('This command can only be run on cloud'); - - return; - } - $this->info('Cleaning up subcriptions teams'); - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); - - $teams = Team::all()->filter(function ($team) { - return $team->id !== 0; - })->sortBy('id'); - foreach ($teams as $team) { - if ($team) { - $this->info("Checking team {$team->id}"); - } - if (! data_get($team, 'subscription')) { - $this->disableServers($team); - - continue; - } - // If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status - if (! (data_get($team, 'subscription.stripe_subscription_id'))) { - $this->info("Resetting invoice paid status for team {$team->id}"); - - $team->subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_subscription_id' => null, - ]); - $this->disableServers($team); - - continue; - } else { - $subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []); - $status = data_get($subscription, 'status'); - if ($status === 'active') { - $team->subscription->update([ - 'stripe_invoice_paid' => true, - 'stripe_trial_already_ended' => false, - ]); - - continue; - } - $this->info('Subscription status: '.$status); - $this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id')); - $confirm = $this->confirm('Do you want to cancel the subscription?', true); - if (! $confirm) { - $this->info("Skipping team {$team->id}"); - } else { - $this->info("Cancelling subscription for team {$team->id}"); - $team->subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_subscription_id' => null, - ]); - $this->disableServers($team); - } - } - } - } catch (\Exception $e) { - $this->error($e->getMessage()); - - return; - } - } - - private function disableServers(Team $team) - { - foreach ($team->servers as $server) { - if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') { - $this->info("Disabling server {$server->id} {$server->name}"); - $server->settings()->update([ - 'is_usable' => false, - 'is_reachable' => false, - ]); - $server->update([ - 'ip' => '1.2.3.4', - ]); - - ServerReachabilityChanged::dispatch($server); - } - } - } -} diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index a4cfde6f8..8f26d78ff 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Jobs\CheckHelperImageJob; use App\Models\InstanceSettings; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; @@ -44,5 +45,6 @@ public function init() } else { echo "Instance already initialized.\n"; } + CheckHelperImageJob::dispatch(); } } diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php index 577e94ac8..42f9360bb 100644 --- a/app/Console/Commands/Generate/Services.php +++ b/app/Console/Commands/Generate/Services.php @@ -16,7 +16,7 @@ class Services extends Command /** * {@inheritdoc} */ - protected $description = 'Generate service-templates.yaml based on /templates/compose directory'; + protected $description = 'Generates service-templates json file based on /templates/compose directory'; public function handle(): int { @@ -33,7 +33,10 @@ public function handle(): int ]; })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL); + file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL); + + // Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN + $this->generateServiceTemplatesWithFqdn(); return self::SUCCESS; } @@ -71,6 +74,7 @@ private function processFile(string $file): false|array 'slogan' => $data->get('slogan', str($file)->headline()), 'compose' => $compose, 'tags' => $tags, + 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), ]; @@ -86,4 +90,145 @@ private function processFile(string $file): false|array return $payload; } + + private function generateServiceTemplatesWithFqdn(): void + { + $serviceTemplatesWithFqdn = collect(array_merge( + glob(base_path('templates/compose/*.yaml')), + glob(base_path('templates/compose/*.yml')) + )) + ->mapWithKeys(function ($file): array { + $file = basename($file); + $parsed = $this->processFileWithFqdn($file); + + return $parsed === false ? [] : [ + Arr::pull($parsed, 'name') => $parsed, + ]; + })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL); + + // Generate service-templates-raw.json with non-base64 encoded compose content + // $this->generateServiceTemplatesRaw(); + } + + private function processFileWithFqdn(string $file): false|array + { + $content = file_get_contents(base_path("templates/compose/$file")); + + $data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array { + preg_match('/^#(?.*):(?.*)$/U', $line, $m); + + return $m ? [trim($m['key']) => trim($m['value'])] : []; + }); + + if (str($data->get('ignore'))->toBoolean()) { + return false; + } + + $documentation = $data->get('documentation'); + $documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs'; + + // Replace SERVICE_URL with SERVICE_FQDN in the content + $modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content); + + $json = Yaml::parse($modifiedContent); + $compose = base64_encode(Yaml::dump($json, 10, 2)); + + $tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter(); + $tags = $tags->isEmpty() ? null : $tags->all(); + + $payload = [ + 'name' => pathinfo($file, PATHINFO_FILENAME), + 'documentation' => $documentation, + 'slogan' => $data->get('slogan', str($file)->headline()), + 'compose' => $compose, + 'tags' => $tags, + 'category' => $data->get('category'), + 'logo' => $data->get('logo', 'svgs/default.webp'), + 'minversion' => $data->get('minversion', '0.0.0'), + ]; + + if ($port = $data->get('port')) { + $payload['port'] = $port; + } + + if ($envFile = $data->get('env_file')) { + $envFileContent = file_get_contents(base_path("templates/compose/$envFile")); + // Also replace SERVICE_URL with SERVICE_FQDN in env file content + $modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent); + $payload['envs'] = base64_encode($modifiedEnvContent); + } + + return $payload; + } + + private function generateServiceTemplatesRaw(): void + { + $serviceTemplatesRaw = collect(array_merge( + glob(base_path('templates/compose/*.yaml')), + glob(base_path('templates/compose/*.yml')) + )) + ->mapWithKeys(function ($file): array { + $file = basename($file); + $parsed = $this->processFileWithFqdnRaw($file); + + return $parsed === false ? [] : [ + Arr::pull($parsed, 'name') => $parsed, + ]; + })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + file_put_contents(base_path('templates/service-templates-raw.json'), $serviceTemplatesRaw.PHP_EOL); + } + + private function processFileWithFqdnRaw(string $file): false|array + { + $content = file_get_contents(base_path("templates/compose/$file")); + + $data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array { + preg_match('/^#(?.*):(?.*)$/U', $line, $m); + + return $m ? [trim($m['key']) => trim($m['value'])] : []; + }); + + if (str($data->get('ignore'))->toBoolean()) { + return false; + } + + $documentation = $data->get('documentation'); + $documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs'; + + // Replace SERVICE_URL with SERVICE_FQDN in the content + $modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content); + + $json = Yaml::parse($modifiedContent); + $compose = Yaml::dump($json, 10, 2); // Not base64 encoded + + $tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter(); + $tags = $tags->isEmpty() ? null : $tags->all(); + + $payload = [ + 'name' => pathinfo($file, PATHINFO_FILENAME), + 'documentation' => $documentation, + 'slogan' => $data->get('slogan', str($file)->headline()), + 'compose' => $compose, + 'tags' => $tags, + 'category' => $data->get('category'), + 'logo' => $data->get('logo', 'svgs/default.webp'), + 'minversion' => $data->get('minversion', '0.0.0'), + ]; + + if ($port = $data->get('port')) { + $payload['port'] = $port; + } + + if ($envFile = $data->get('env_file')) { + $envFileContent = file_get_contents(base_path("templates/compose/$envFile")); + // Also replace SERVICE_URL with SERVICE_FQDN in env file content (not base64 encoded) + $modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent); + $payload['envs'] = $modifiedEnvContent; + } + + return $payload; + } } diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index a954b10fd..6e8d18f61 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -5,8 +5,10 @@ use App\Enums\ActivityTypes; use App\Enums\ApplicationDeploymentStatus; use App\Jobs\CheckHelperImageJob; +use App\Jobs\PullChangelog; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; @@ -18,81 +20,104 @@ class Init extends Command { - protected $signature = 'app:init {--force-cloud}'; + protected $signature = 'app:init'; protected $description = 'Cleanup instance related stuffs'; public $servers = null; + public InstanceSettings $settings; + public function handle() { - $this->optimize(); + Artisan::call('optimize:clear'); + Artisan::call('optimize'); - if (isCloud() && ! $this->option('force-cloud')) { - echo "Skipping init as we are on cloud and --force-cloud option is not set\n"; - - return; + try { + $this->pullTemplatesFromCDN(); + } catch (\Throwable $e) { + echo "Could not pull templates from CDN: {$e->getMessage()}\n"; } - $this->servers = Server::all(); - if (! isCloud()) { - $this->send_alive_signal(); - get_public_ips(); + try { + $this->pullChangelogFromGitHub(); + } catch (\Throwable $e) { + echo "Could not changelogs from github: {$e->getMessage()}\n"; } - // Backward compatibility - $this->replace_slash_in_environment_name(); - $this->restore_coolify_db_backup(); - $this->update_user_emails(); - // - $this->update_traefik_labels(); - if (! isCloud() || $this->option('force-cloud')) { - $this->cleanup_unused_network_from_coolify_proxy(); - } - if (isCloud()) { - $this->cleanup_unnecessary_dynamic_proxy_configuration(); - } else { - $this->cleanup_in_progress_application_deployments(); - } - $this->call('cleanup:redis'); - - $this->call('cleanup:stucked-resources'); - try { $this->pullHelperImage(); } catch (\Throwable $e) { - // + echo "Error in pullHelperImage command: {$e->getMessage()}\n"; } if (isCloud()) { - try { - $this->pullTemplatesFromCDN(); - } catch (\Throwable $e) { - echo "Could not pull templates from CDN: {$e->getMessage()}\n"; - } + return; } - if (! isCloud()) { - try { - $this->pullTemplatesFromCDN(); - } catch (\Throwable $e) { - echo "Could not pull templates from CDN: {$e->getMessage()}\n"; + $this->settings = instanceSettings(); + $this->servers = Server::all(); + + $do_not_track = data_get($this->settings, 'do_not_track', true); + if ($do_not_track == false) { + $this->sendAliveSignal(); + } + get_public_ips(); + + // Backward compatibility + $this->replaceSlashInEnvironmentName(); + $this->restoreCoolifyDbBackup(); + $this->updateUserEmails(); + // + $this->updateTraefikLabels(); + $this->cleanupUnusedNetworkFromCoolifyProxy(); + + try { + $this->call('cleanup:redis'); + } catch (\Throwable $e) { + echo "Error in cleanup:redis command: {$e->getMessage()}\n"; + } + try { + $this->call('cleanup:names'); + } catch (\Throwable $e) { + echo "Error in cleanup:names command: {$e->getMessage()}\n"; + } + try { + $this->call('cleanup:stucked-resources'); + } catch (\Throwable $e) { + echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n"; + } + try { + $updatedCount = ApplicationDeploymentQueue::whereIn('status', [ + ApplicationDeploymentStatus::IN_PROGRESS->value, + ApplicationDeploymentStatus::QUEUED->value, + ])->update([ + 'status' => ApplicationDeploymentStatus::FAILED->value, + ]); + + if ($updatedCount > 0) { + echo "Marked {$updatedCount} stuck deployments as failed\n"; } - try { - $localhost = $this->servers->where('id', 0)->first(); + } catch (\Throwable $e) { + echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n"; + } + + try { + $localhost = $this->servers->where('id', 0)->first(); + if ($localhost) { $localhost->setupDynamicProxyConfiguration(); - } catch (\Throwable $e) { - echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; } - $settings = instanceSettings(); - if (! is_null(config('constants.coolify.autoupdate', null))) { - if (config('constants.coolify.autoupdate') == true) { - echo "Enabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => true]); - } else { - echo "Disabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => false]); - } + } catch (\Throwable $e) { + echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; + } + + if (! is_null(config('constants.coolify.autoupdate', null))) { + if (config('constants.coolify.autoupdate') == true) { + echo "Enabling auto-update\n"; + $this->settings->update(['is_auto_update_enabled' => true]); + } else { + echo "Disabling auto-update\n"; + $this->settings->update(['is_auto_update_enabled' => false]); } } } @@ -107,28 +132,32 @@ private function pullTemplatesFromCDN() $response = Http::retry(3, 1000)->get(config('constants.services.official')); if ($response->successful()) { $services = $response->json(); - File::put(base_path('templates/service-templates.json'), json_encode($services)); + File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services)); } } - private function optimize() + private function pullChangelogFromGitHub() { - Artisan::call('optimize:clear'); - Artisan::call('optimize'); + try { + PullChangelog::dispatch(); + echo "Changelog fetch initiated\n"; + } catch (\Throwable $e) { + echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n"; + } } - private function update_user_emails() + private function updateUserEmails() { try { User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) { - $user->update(['email' => strtolower($user->email)]); + $user->update(['email' => $user->email]); }); } catch (\Throwable $e) { echo "Error in updating user emails: {$e->getMessage()}\n"; } } - private function update_traefik_labels() + private function updateTraefikLabels() { try { Server::where('proxy->type', 'TRAEFIK_V2')->update(['proxy->type' => 'TRAEFIK']); @@ -137,28 +166,7 @@ private function update_traefik_labels() } } - private function cleanup_unnecessary_dynamic_proxy_configuration() - { - foreach ($this->servers as $server) { - try { - if (! $server->isFunctional()) { - continue; - } - if ($server->id === 0) { - continue; - } - $file = $server->proxyPath().'/dynamic/coolify.yaml'; - - return instant_remote_process([ - "rm -f $file", - ], $server, false); - } catch (\Throwable $e) { - echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; - } - } - } - - private function cleanup_unused_network_from_coolify_proxy() + private function cleanupUnusedNetworkFromCoolifyProxy() { foreach ($this->servers as $server) { if (! $server->isFunctional()) { @@ -197,7 +205,7 @@ private function cleanup_unused_network_from_coolify_proxy() } } - private function restore_coolify_db_backup() + private function restoreCoolifyDbBackup() { if (version_compare('4.0.0-beta.179', config('constants.coolify.version'), '<=')) { try { @@ -223,17 +231,10 @@ private function restore_coolify_db_backup() } } - private function send_alive_signal() + private function sendAliveSignal() { $id = config('app.id'); $version = config('constants.coolify.version'); - $settings = instanceSettings(); - $do_not_track = data_get($settings, 'do_not_track'); - if ($do_not_track == true) { - echo "Do_not_track is enabled\n"; - - return; - } try { Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version"); } catch (\Throwable $e) { @@ -241,24 +242,7 @@ private function send_alive_signal() } } - private function cleanup_in_progress_application_deployments() - { - // Cleanup any failed deployments - try { - if (isCloud()) { - return; - } - $queued_inprogress_deployments = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])->get(); - foreach ($queued_inprogress_deployments as $deployment) { - $deployment->status = ApplicationDeploymentStatus::FAILED->value; - $deployment->save(); - } - } catch (\Throwable $e) { - echo "Error: {$e->getMessage()}\n"; - } - } - - private function replace_slash_in_environment_name() + private function replaceSlashInEnvironmentName() { if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) { $environments = Environment::all(); diff --git a/app/Console/Commands/RunScheduledJobsManually.php b/app/Console/Commands/RunScheduledJobsManually.php new file mode 100644 index 000000000..238bcbce3 --- /dev/null +++ b/app/Console/Commands/RunScheduledJobsManually.php @@ -0,0 +1,247 @@ +option('type'); + $frequency = $this->option('frequency'); + $chunkSize = (int) $this->option('chunk'); + $delay = (int) $this->option('delay'); + $maxJobs = $this->option('max') ? (int) $this->option('max') : null; + $dryRun = $this->option('dry-run'); + + $this->info('Starting manual execution of scheduled jobs...'.($dryRun ? ' (DRY RUN)' : '')); + $this->info("Type: {$type}".($frequency ? ", Frequency: {$frequency}" : '').", Chunk size: {$chunkSize}, Delay: {$delay}s".($maxJobs ? ", Max jobs: {$maxJobs}" : '').($dryRun ? ', Dry run: enabled' : '')); + + if ($dryRun) { + $this->warn('DRY RUN MODE: No jobs will actually be dispatched'); + } + + if ($type === 'all' || $type === 'backups') { + $this->runScheduledBackups($chunkSize, $delay, $maxJobs, $dryRun, $frequency); + } + + if ($type === 'all' || $type === 'tasks') { + $this->runScheduledTasks($chunkSize, $delay, $maxJobs, $dryRun, $frequency); + } + + $this->info('Completed manual execution of scheduled jobs.'.($dryRun ? ' (DRY RUN)' : '')); + } + + private function runScheduledBackups(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void + { + $this->info('Processing scheduled database backups...'); + + $query = ScheduledDatabaseBackup::where('enabled', true); + + if ($frequency) { + $query->where(function ($q) use ($frequency) { + // Handle human-readable frequency strings + if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) { + $q->where('frequency', $frequency); + } else { + // Handle cron expressions + $q->where('frequency', $frequency); + } + }); + } + + $scheduled_backups = $query->get(); + + if ($scheduled_backups->isEmpty()) { + $this->info('No enabled scheduled backups found'.($frequency ? " with frequency '{$frequency}'" : '').'.'); + + return; + } + + $finalScheduledBackups = collect(); + + foreach ($scheduled_backups as $scheduled_backup) { + if (blank(data_get($scheduled_backup, 'database'))) { + $this->warn("Deleting backup {$scheduled_backup->id} - missing database"); + $scheduled_backup->delete(); + + continue; + } + + $server = $scheduled_backup->server(); + if (blank($server)) { + $this->warn("Deleting backup {$scheduled_backup->id} - missing server"); + $scheduled_backup->delete(); + + continue; + } + + if ($server->isFunctional() === false) { + $this->warn("Skipping backup {$scheduled_backup->id} - server not functional"); + + continue; + } + + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + $this->warn("Skipping backup {$scheduled_backup->id} - subscription not paid"); + + continue; + } + + $finalScheduledBackups->push($scheduled_backup); + } + + if ($maxJobs && $finalScheduledBackups->count() > $maxJobs) { + $finalScheduledBackups = $finalScheduledBackups->take($maxJobs); + $this->info("Limited to {$maxJobs} scheduled backups for testing"); + } + + $this->info("Found {$finalScheduledBackups->count()} valid scheduled backups to process".($frequency ? " with frequency '{$frequency}'" : '')); + + $chunks = $finalScheduledBackups->chunk($chunkSize); + foreach ($chunks as $index => $chunk) { + $this->info('Processing backup batch '.($index + 1).' of '.$chunks->count()." ({$chunk->count()} items)"); + + foreach ($chunk as $scheduled_backup) { + try { + if ($dryRun) { + $this->info("🔍 Would dispatch backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})"); + } else { + DatabaseBackupJob::dispatch($scheduled_backup); + $this->info("✓ Dispatched backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})"); + } + } catch (\Exception $e) { + $this->error("✗ Failed to dispatch backup job for {$scheduled_backup->id}: ".$e->getMessage()); + Log::error('Error dispatching backup job: '.$e->getMessage()); + } + } + + if ($index < $chunks->count() - 1 && ! $dryRun) { + $this->info("Waiting {$delay} seconds before next batch..."); + sleep($delay); + } + } + } + + private function runScheduledTasks(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void + { + $this->info('Processing scheduled tasks...'); + + $query = ScheduledTask::where('enabled', true); + + if ($frequency) { + $query->where(function ($q) use ($frequency) { + // Handle human-readable frequency strings + if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) { + $q->where('frequency', $frequency); + } else { + // Handle cron expressions + $q->where('frequency', $frequency); + } + }); + } + + $scheduled_tasks = $query->get(); + + if ($scheduled_tasks->isEmpty()) { + $this->info('No enabled scheduled tasks found'.($frequency ? " with frequency '{$frequency}'" : '').'.'); + + return; + } + + $finalScheduledTasks = collect(); + + foreach ($scheduled_tasks as $scheduled_task) { + $service = $scheduled_task->service; + $application = $scheduled_task->application; + + $server = $scheduled_task->server(); + if (blank($server)) { + $this->warn("Deleting task {$scheduled_task->id} - missing server"); + $scheduled_task->delete(); + + continue; + } + + if ($server->isFunctional() === false) { + $this->warn("Skipping task {$scheduled_task->id} - server not functional"); + + continue; + } + + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + $this->warn("Skipping task {$scheduled_task->id} - subscription not paid"); + + continue; + } + + if (! $service && ! $application) { + $this->warn("Deleting task {$scheduled_task->id} - missing service and application"); + $scheduled_task->delete(); + + continue; + } + + if ($application && str($application->status)->contains('running') === false) { + $this->warn("Skipping task {$scheduled_task->id} - application not running"); + + continue; + } + + if ($service && str($service->status)->contains('running') === false) { + $this->warn("Skipping task {$scheduled_task->id} - service not running"); + + continue; + } + + $finalScheduledTasks->push($scheduled_task); + } + + if ($maxJobs && $finalScheduledTasks->count() > $maxJobs) { + $finalScheduledTasks = $finalScheduledTasks->take($maxJobs); + $this->info("Limited to {$maxJobs} scheduled tasks for testing"); + } + + $this->info("Found {$finalScheduledTasks->count()} valid scheduled tasks to process".($frequency ? " with frequency '{$frequency}'" : '')); + + $chunks = $finalScheduledTasks->chunk($chunkSize); + foreach ($chunks as $index => $chunk) { + $this->info('Processing task batch '.($index + 1).' of '.$chunks->count()." ({$chunk->count()} items)"); + + foreach ($chunk as $scheduled_task) { + try { + if ($dryRun) { + $this->info("🔍 Would dispatch task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})"); + } else { + ScheduledTaskJob::dispatch($scheduled_task); + $this->info("✓ Dispatched task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})"); + } + } catch (\Exception $e) { + $this->error("✗ Failed to dispatch task job for {$scheduled_task->id}: ".$e->getMessage()); + Log::error('Error dispatching task job: '.$e->getMessage()); + } + } + + if ($index < $chunks->count() - 1 && ! $dryRun) { + $this->info("Waiting {$delay} seconds before next batch..."); + sleep($delay); + } + } + } +} diff --git a/app/Console/Commands/ServicesDelete.php b/app/Console/Commands/ServicesDelete.php index b5a74166a..870cef3d9 100644 --- a/app/Console/Commands/ServicesDelete.php +++ b/app/Console/Commands/ServicesDelete.php @@ -6,7 +6,14 @@ use App\Models\Application; use App\Models\Server; use App\Models\Service; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use Illuminate\Console\Command; use function Laravel\Prompts\confirm; @@ -103,19 +110,79 @@ private function deleteApplication() private function deleteDatabase() { - $databases = StandalonePostgresql::all(); - if ($databases->count() === 0) { + // Collect all databases from all types with unique identifiers + $allDatabases = collect(); + $databaseOptions = collect(); + + // Add PostgreSQL databases + foreach (StandalonePostgresql::all() as $db) { + $key = "postgresql_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (PostgreSQL)"); + } + + // Add MySQL databases + foreach (StandaloneMysql::all() as $db) { + $key = "mysql_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MySQL)"); + } + + // Add MariaDB databases + foreach (StandaloneMariadb::all() as $db) { + $key = "mariadb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MariaDB)"); + } + + // Add MongoDB databases + foreach (StandaloneMongodb::all() as $db) { + $key = "mongodb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MongoDB)"); + } + + // Add Redis databases + foreach (StandaloneRedis::all() as $db) { + $key = "redis_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (Redis)"); + } + + // Add KeyDB databases + foreach (StandaloneKeydb::all() as $db) { + $key = "keydb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (KeyDB)"); + } + + // Add Dragonfly databases + foreach (StandaloneDragonfly::all() as $db) { + $key = "dragonfly_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (Dragonfly)"); + } + + // Add ClickHouse databases + foreach (StandaloneClickhouse::all() as $db) { + $key = "clickhouse_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (ClickHouse)"); + } + + if ($allDatabases->count() === 0) { $this->error('There are no databases to delete.'); return; } + $databasesToDelete = multiselect( 'What database do you want to delete?', - $databases->pluck('name', 'id')->sortKeys(), + $databaseOptions->sortKeys(), ); - foreach ($databasesToDelete as $database) { - $toDelete = $databases->where('id', $database)->first(); + foreach ($databasesToDelete as $databaseKey) { + $toDelete = $allDatabases->get($databaseKey); if ($toDelete) { $this->info($toDelete); $confirmed = confirm('Are you sure you want to delete all selected resources?'); diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index df1903828..b0cd24715 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}'; /** * The console command description. @@ -25,6 +25,50 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; + /** + * Fetch GitHub releases and sync to CDN + */ + private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn) + { + $this->info('Fetching releases from GitHub...'); + try { + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, // Fetch more releases for better changelog + ]); + + if ($response->successful()) { + $releases = $response->json(); + + // Save releases to a temporary file + $releases_file = "$parent_dir/releases.json"; + file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // Upload to CDN + Http::pool(fn (Pool $pool) => [ + $pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"), + $pool->purge("$bunny_cdn/coolify/releases.json"), + ]); + + // Clean up temporary file + unlink($releases_file); + + $this->info('releases.json uploaded & purged...'); + $this->info('Total releases synced: '.count($releases)); + + return true; + } else { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + } catch (\Throwable $e) { + $this->error('Error fetching releases: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ @@ -33,6 +77,7 @@ public function handle() $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); + $only_github_releases = $this->option('github-releases'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -45,7 +90,7 @@ public function handle() $install_script = 'install.sh'; $upgrade_script = 'upgrade.sh'; $production_env = '.env.production'; - $service_template = 'service-templates.json'; + $service_template = config('constants.services.file_name'); $versions = 'versions.json'; $compose_file_location = "$parent_dir/$compose_file"; @@ -90,7 +135,7 @@ public function handle() $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version) { + if (! $only_template && ! $only_version && ! $only_github_releases) { if ($nightly) { $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); } else { @@ -102,7 +147,7 @@ public function handle() } } if ($only_template) { - $this->info('About to sync service-templates.json to BunnyCDN.'); + $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.'); $confirmed = confirm('Are you sure you want to sync?'); if (! $confirmed) { return; @@ -128,12 +173,29 @@ public function handle() if (! $confirmed) { return; } + + // First sync GitHub releases + $this->info('Syncing GitHub releases first...'); + $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + + // Then sync versions.json Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), ]); $this->info('versions.json uploaded & purged...'); + return; + } elseif ($only_github_releases) { + $this->info('About to sync GitHub releases to BunnyCDN.'); + $confirmed = confirm('Are you sure you want to sync GitHub releases?'); + if (! $confirmed) { + return; + } + + // Use the reusable function + $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + return; } diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php new file mode 100644 index 000000000..9ecf90716 --- /dev/null +++ b/app/Console/Commands/ViewScheduledLogs.php @@ -0,0 +1,278 @@ +option('date') ?: now()->format('Y-m-d'); + $logPaths = $this->getLogPaths($date); + + if (empty($logPaths)) { + $this->showAvailableLogFiles($date); + + return; + } + + $lines = $this->option('lines'); + $follow = $this->option('follow'); + + // Build grep filters + $filters = $this->buildFilters(); + $filterDescription = $this->getFilterDescription(); + $logTypeDescription = $this->getLogTypeDescription(); + + if ($follow) { + $this->info("Following {$logTypeDescription} logs for {$date}{$filterDescription} (Press Ctrl+C to stop)..."); + $this->line(''); + + if (count($logPaths) === 1) { + $logPath = $logPaths[0]; + if ($filters) { + passthru("tail -f {$logPath} | grep -E '{$filters}'"); + } else { + passthru("tail -f {$logPath}"); + } + } else { + // Multiple files - use multitail or tail with process substitution + $logPathsStr = implode(' ', $logPaths); + if ($filters) { + passthru("tail -f {$logPathsStr} | grep -E '{$filters}'"); + } else { + passthru("tail -f {$logPathsStr}"); + } + } + } else { + $this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:"); + $this->line(''); + + if (count($logPaths) === 1) { + $logPath = $logPaths[0]; + if ($filters) { + passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'"); + } else { + passthru("tail -n {$lines} {$logPath}"); + } + } else { + // Multiple files - concatenate and sort by timestamp + $logPathsStr = implode(' ', $logPaths); + if ($filters) { + passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'"); + } else { + passthru("tail -n {$lines} {$logPathsStr} | sort"); + } + } + } + } + + private function getLogPaths(string $date): array + { + $paths = []; + + if ($this->option('errors')) { + // Error logs only + $errorPath = storage_path("logs/scheduled-errors-{$date}.log"); + if (File::exists($errorPath)) { + $paths[] = $errorPath; + } + } elseif ($this->option('all')) { + // Both normal and error logs + $normalPath = storage_path("logs/scheduled-{$date}.log"); + $errorPath = storage_path("logs/scheduled-errors-{$date}.log"); + + if (File::exists($normalPath)) { + $paths[] = $normalPath; + } + if (File::exists($errorPath)) { + $paths[] = $errorPath; + } + } else { + // Normal logs only (default) + $normalPath = storage_path("logs/scheduled-{$date}.log"); + if (File::exists($normalPath)) { + $paths[] = $normalPath; + } + } + + return $paths; + } + + private function showAvailableLogFiles(string $date): void + { + $logType = $this->getLogTypeDescription(); + $this->warn("No {$logType} logs found for date {$date}"); + + // Show available log files + $normalFiles = File::glob(storage_path('logs/scheduled-*.log')); + $errorFiles = File::glob(storage_path('logs/scheduled-errors-*.log')); + + if (! empty($normalFiles) || ! empty($errorFiles)) { + $this->info('Available scheduled log files:'); + + if (! empty($normalFiles)) { + $this->line(' Normal logs:'); + foreach ($normalFiles as $file) { + $basename = basename($file); + $this->line(" - {$basename}"); + } + } + + if (! empty($errorFiles)) { + $this->line(' Error logs:'); + foreach ($errorFiles as $file) { + $basename = basename($file); + $this->line(" - {$basename}"); + } + } + } + } + + private function getLogTypeDescription(): string + { + if ($this->option('errors')) { + return 'error'; + } elseif ($this->option('all')) { + return 'all'; + } else { + return 'normal'; + } + } + + private function buildFilters(): ?string + { + $filters = []; + + if ($taskName = $this->option('task-name')) { + $filters[] = '"task_name":"[^"]*'.preg_quote($taskName, '/').'[^"]*"'; + } + + if ($taskId = $this->option('task-id')) { + $filters[] = '"task_id":'.preg_quote($taskId, '/'); + } + + if ($backupName = $this->option('backup-name')) { + $filters[] = '"backup_name":"[^"]*'.preg_quote($backupName, '/').'[^"]*"'; + } + + if ($backupId = $this->option('backup-id')) { + $filters[] = '"backup_id":'.preg_quote($backupId, '/'); + } + + // Frequency filters + if ($this->option('hourly')) { + $filters[] = $this->getFrequencyPattern('hourly'); + } + + if ($this->option('daily')) { + $filters[] = $this->getFrequencyPattern('daily'); + } + + if ($this->option('weekly')) { + $filters[] = $this->getFrequencyPattern('weekly'); + } + + if ($this->option('monthly')) { + $filters[] = $this->getFrequencyPattern('monthly'); + } + + if ($frequency = $this->option('frequency')) { + $filters[] = '"frequency":"'.preg_quote($frequency, '/').'"'; + } + + return empty($filters) ? null : implode('|', $filters); + } + + private function getFrequencyPattern(string $type): string + { + $patterns = [ + 'hourly' => [ + '0 \* \* \* \*', // 0 * * * * + '@hourly', // @hourly + ], + 'daily' => [ + '0 0 \* \* \*', // 0 0 * * * + '@daily', // @daily + '@midnight', // @midnight + ], + 'weekly' => [ + '0 0 \* \* [0-6]', // 0 0 * * 0-6 (any day of week) + '@weekly', // @weekly + ], + 'monthly' => [ + '0 0 1 \* \*', // 0 0 1 * * (first of month) + '@monthly', // @monthly + ], + ]; + + $typePatterns = $patterns[$type] ?? []; + + // For grep, we need to match the frequency field in JSON + return '"frequency":"('.implode('|', $typePatterns).')"'; + } + + private function getFilterDescription(): string + { + $descriptions = []; + + if ($taskName = $this->option('task-name')) { + $descriptions[] = "task name: {$taskName}"; + } + + if ($taskId = $this->option('task-id')) { + $descriptions[] = "task ID: {$taskId}"; + } + + if ($backupName = $this->option('backup-name')) { + $descriptions[] = "backup name: {$backupName}"; + } + + if ($backupId = $this->option('backup-id')) { + $descriptions[] = "backup ID: {$backupId}"; + } + + // Frequency filters + if ($this->option('hourly')) { + $descriptions[] = 'hourly jobs'; + } + + if ($this->option('daily')) { + $descriptions[] = 'daily jobs'; + } + + if ($this->option('weekly')) { + $descriptions[] = 'weekly jobs'; + } + + if ($this->option('monthly')) { + $descriptions[] = 'monthly jobs'; + } + + if ($frequency = $this->option('frequency')) { + $descriptions[] = "frequency: {$frequency}"; + } + + return empty($descriptions) ? '' : ' (filtered by '.implode(', ', $descriptions).')'; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c03475647..c2ea27274 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,22 +6,17 @@ use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CleanupInstanceStuffsJob; -use App\Jobs\DatabaseBackupJob; -use App\Jobs\DockerCleanupJob; +use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; -use App\Jobs\ScheduledTaskJob; -use App\Jobs\ServerCheckJob; -use App\Jobs\ServerStorageCheckJob; +use App\Jobs\ScheduledJobManager; +use App\Jobs\ServerManagerJob; use App\Jobs\UpdateCoolifyJob; use App\Models\InstanceSettings; -use App\Models\ScheduledDatabaseBackup; -use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; class Kernel extends ConsoleKernel @@ -51,7 +46,7 @@ protected function schedule(Schedule $schedule): void } // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); - $this->scheduleInstance->command('cleanup:redis')->hourly(); + $this->scheduleInstance->command('cleanup:redis')->weekly(); if (isDev()) { // Instance Jobs @@ -60,10 +55,10 @@ protected function schedule(Schedule $schedule): void $this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer(); // Server Jobs - $this->checkResources(); + $this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer(); - $this->checkScheduledBackups(); - $this->checkScheduledTasks(); + // Scheduled Jobs (Backups & Tasks) + $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); @@ -73,17 +68,18 @@ protected function schedule(Schedule $schedule): void $this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer(); $this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); + $this->scheduleInstance->job(new PullChangelog)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); $this->scheduleUpdates(); // Server Jobs - $this->checkResources(); + $this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer(); $this->pullImages(); - $this->checkScheduledBackups(); - $this->checkScheduledTasks(); + // Scheduled Jobs (Backups & Tasks) + $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); @@ -134,179 +130,6 @@ private function scheduleUpdates(): void } } - private function checkResources(): void - { - if (isCloud()) { - $servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers; - $servers = $servers->merge($own); - } else { - $servers = $this->allServers->get(); - } - - foreach ($servers as $server) { - try { - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - - // Sentinel check - $lastSentinelUpdate = $server->sentinel_updated_at; - if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) { - // Check container status every minute if Sentinel does not activated - if (isCloud()) { - $this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyFiveMinutes()->onOneServer(); - } else { - $this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyMinute()->onOneServer(); - } - // $this->scheduleInstance->job(new \App\Jobs\ServerCheckNewJob($server))->everyFiveMinutes()->onOneServer(); - - $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *'); - if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { - $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; - } - $this->scheduleInstance->job(new ServerStorageCheckJob($server))->cron($serverDiskUsageCheckFrequency)->timezone($serverTimezone)->onOneServer(); - } - - $dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); - if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) { - $dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency]; - } - $this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer(); - - // Cleanup multiplexed connections every hour - // $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer(); - - // Temporary solution until we have better memory management for Sentinel - if ($server->isSentinelEnabled()) { - $this->scheduleInstance->job(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - })->daily()->onOneServer(); - } - } catch (\Exception $e) { - Log::error('Error checking resources: '.$e->getMessage()); - } - } - } - - private function checkScheduledBackups(): void - { - $scheduled_backups = ScheduledDatabaseBackup::where('enabled', true)->get(); - if ($scheduled_backups->isEmpty()) { - return; - } - $finalScheduledBackups = collect(); - foreach ($scheduled_backups as $scheduled_backup) { - if (blank(data_get($scheduled_backup, 'database'))) { - $scheduled_backup->delete(); - - continue; - } - $server = $scheduled_backup->server(); - if (blank($server)) { - $scheduled_backup->delete(); - - continue; - } - if ($server->isFunctional() === false) { - continue; - } - if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - continue; - } - $finalScheduledBackups->push($scheduled_backup); - } - - foreach ($finalScheduledBackups as $scheduled_backup) { - try { - if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) { - $scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency]; - } - $server = $scheduled_backup->server(); - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - - if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) { - $scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency]; - } - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - $this->scheduleInstance->job(new DatabaseBackupJob( - backup: $scheduled_backup - ))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer(); - } catch (\Exception $e) { - Log::error('Error scheduling backup: '.$e->getMessage()); - Log::error($e->getTraceAsString()); - } - } - } - - private function checkScheduledTasks(): void - { - $scheduled_tasks = ScheduledTask::where('enabled', true)->get(); - if ($scheduled_tasks->isEmpty()) { - return; - } - $finalScheduledTasks = collect(); - foreach ($scheduled_tasks as $scheduled_task) { - $service = $scheduled_task->service; - $application = $scheduled_task->application; - - $server = $scheduled_task->server(); - if (blank($server)) { - $scheduled_task->delete(); - - continue; - } - - if ($server->isFunctional() === false) { - continue; - } - - if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - continue; - } - - if (! $service && ! $application) { - $scheduled_task->delete(); - - continue; - } - - if ($application && str($application->status)->contains('running') === false) { - continue; - } - if ($service && str($service->status)->contains('running') === false) { - continue; - } - - $finalScheduledTasks->push($scheduled_task); - } - - foreach ($finalScheduledTasks as $scheduled_task) { - try { - $server = $scheduled_task->server(); - if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) { - $scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency]; - } - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - $this->scheduleInstance->job(new ScheduledTaskJob( - task: $scheduled_task - ))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer(); - } catch (\Exception $e) { - Log::error('Error scheduling task: '.$e->getMessage()); - Log::error($e->getTraceAsString()); - } - } - } - protected function commands(): void { $this->load(__DIR__.'/Commands'); diff --git a/app/Events/ApplicationConfigurationChanged.php b/app/Events/ApplicationConfigurationChanged.php new file mode 100644 index 000000000..3dd532b19 --- /dev/null +++ b/app/Events/ApplicationConfigurationChanged.php @@ -0,0 +1,35 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Events/BackupCreated.php b/app/Events/BackupCreated.php index bc1ecee0d..9670f5c3c 100644 --- a/app/Events/BackupCreated.php +++ b/app/Events/BackupCreated.php @@ -7,8 +7,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Laravel\Horizon\Contracts\Silenced; -class BackupCreated implements ShouldBroadcast +class BackupCreated implements ShouldBroadcast, Silenced { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/ProxyStarted.php b/app/Events/CloudflareTunnelChanged.php similarity index 90% rename from app/Events/ProxyStarted.php rename to app/Events/CloudflareTunnelChanged.php index 64d562e0a..afa848cc2 100644 --- a/app/Events/ProxyStarted.php +++ b/app/Events/CloudflareTunnelChanged.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ProxyStarted +class CloudflareTunnelChanged { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/ProxyStatusChanged.php b/app/Events/ProxyStatusChanged.php index 6099d25ef..0cb775eb3 100644 --- a/app/Events/ProxyStatusChanged.php +++ b/app/Events/ProxyStatusChanged.php @@ -3,33 +3,12 @@ namespace App\Events; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ProxyStatusChanged implements ShouldBroadcast +class ProxyStatusChanged { use Dispatchable, InteractsWithSockets, SerializesModels; - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + public function __construct(public $data) {} } diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php new file mode 100644 index 000000000..bd99a0f3c --- /dev/null +++ b/app/Events/ProxyStatusChangedUI.php @@ -0,0 +1,35 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Events/SentinelRestarted.php b/app/Events/SentinelRestarted.php new file mode 100644 index 000000000..9ddc3a07f --- /dev/null +++ b/app/Events/SentinelRestarted.php @@ -0,0 +1,39 @@ +teamId = $server->team_id; + $this->serverUuid = $server->uuid; + $this->version = $version; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Events/ServerValidated.php b/app/Events/ServerValidated.php new file mode 100644 index 000000000..95a116ebe --- /dev/null +++ b/app/Events/ServerValidated.php @@ -0,0 +1,51 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + $this->serverUuid = $serverUuid; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } + + public function broadcastAs(): string + { + return 'ServerValidated'; + } + + public function broadcastWith(): array + { + return [ + 'teamId' => $this->teamId, + 'serverUuid' => $this->serverUuid, + ]; + } +} diff --git a/app/Events/ServiceChecked.php b/app/Events/ServiceChecked.php index 3f130a0fb..86a27a892 100644 --- a/app/Events/ServiceChecked.php +++ b/app/Events/ServiceChecked.php @@ -7,8 +7,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Laravel\Horizon\Contracts\Silenced; -class ServiceChecked implements ShouldBroadcast +class ServiceChecked implements ShouldBroadcast, Silenced { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 8c89bb07f..3d731223d 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -29,6 +29,7 @@ class Handler extends ExceptionHandler */ protected $dontReport = [ ProcessException::class, + NonReportableException::class, ]; /** @@ -53,6 +54,35 @@ protected function unauthenticated($request, AuthenticationException $exception) return redirect()->guest($exception->redirectTo($request) ?? route('login')); } + /** + * Render an exception into an HTTP response. + */ + public function render($request, Throwable $e) + { + // Handle authorization exceptions for API routes + if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($request->is('api/*') || $request->expectsJson()) { + // Get the custom message from the policy if available + $message = $e->getMessage(); + + // Clean up the message for API responses (remove HTML tags if present) + $message = strip_tags(str_replace('
', ' ', $message)); + + // If no custom message, use a default one + if (empty($message) || $message === 'This action is unauthorized.') { + $message = 'You are not authorized to perform this action.'; + } + + return response()->json([ + 'message' => $message, + 'error' => 'Unauthorized', + ], 403); + } + } + + return parent::render($request, $e); + } + /** * Register the exception handling callbacks for the application. */ @@ -81,9 +111,14 @@ function (Scope $scope) { ); } ); + // Check for errors that should not be reported to Sentry if (str($e->getMessage())->contains('No space left on device')) { + // Log locally but don't send to Sentry + logger()->warning('Disk space error: '.$e->getMessage()); + return; } + Integration::captureUnhandledException($e); }); } diff --git a/app/Exceptions/NonReportableException.php b/app/Exceptions/NonReportableException.php new file mode 100644 index 000000000..4c4672127 --- /dev/null +++ b/app/Exceptions/NonReportableException.php @@ -0,0 +1,31 @@ +getMessage(), $exception->getCode(), $exception); + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 8da476b9e..f847f33cc 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,7 +4,9 @@ use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; class SshMultiplexingHelper @@ -30,6 +32,7 @@ public static function ensureMultiplexedConnection(Server $server): bool $sshConfig = self::serverSshConfiguration($server); $muxSocket = $sshConfig['muxFilename']; + // Check if connection exists $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; @@ -41,6 +44,24 @@ public static function ensureMultiplexedConnection(Server $server): bool return self::establishNewMultiplexedConnection($server); } + // Connection exists, ensure we have metadata for age tracking + if (self::getConnectionAge($server) === null) { + // Existing connection but no metadata, store current time as fallback + self::storeConnectionMetadata($server); + } + + // Connection exists, check if it needs refresh due to age + if (self::isConnectionExpired($server)) { + return self::refreshMultiplexedConnection($server); + } + + // Perform health check if enabled + if (config('constants.ssh.mux_health_check_enabled')) { + if (! self::isConnectionHealthy($server)) { + return self::refreshMultiplexedConnection($server); + } + } + return true; } @@ -65,6 +86,9 @@ public static function establishNewMultiplexedConnection(Server $server): bool return false; } + // Store connection metadata for tracking + self::storeConnectionMetadata($server); + return true; } @@ -79,6 +103,9 @@ public static function removeMuxFile(Server $server) } $closeCommand .= "{$server->user}@{$server->ip}"; Process::run($closeCommand); + + // Clear connection metadata from cache + self::clearConnectionMetadata($server); } public static function generateScpCommand(Server $server, string $source, string $dest) @@ -94,8 +121,18 @@ public static function generateScpCommand(Server $server, string $source, string if ($server->isIpv6()) { $scp_command .= '-6 '; } - if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + if (self::isMultiplexingEnabled()) { + try { + if (self::ensureMultiplexedConnection($server)) { + $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + } + } catch (\Exception $e) { + Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + // Continue without multiplexing + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -103,7 +140,11 @@ public static function generateScpCommand(Server $server, string $source, string } $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); - $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + if ($server->isIpv6()) { + $scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}"; + } else { + $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + } return $scp_command; } @@ -126,8 +167,16 @@ public static function generateSshCommand(Server $server, string $command) $ssh_command = "timeout $timeout ssh "; - if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + $multiplexingSuccessful = false; + if (self::isMultiplexingEnabled()) { + try { + $multiplexingSuccessful = self::ensureMultiplexedConnection($server); + if ($multiplexingSuccessful) { + $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + } + } catch (\Exception $e) { + // Continue without multiplexing + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -182,4 +231,81 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati return $options; } + + /** + * Check if the multiplexed connection is healthy by running a test command + */ + public static function isConnectionHealthy(Server $server): bool + { + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout'); + + $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'"; + + $process = Process::run($healthCommand); + $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); + + return $isHealthy; + } + + /** + * Check if the connection has exceeded its maximum age + */ + public static function isConnectionExpired(Server $server): bool + { + $connectionAge = self::getConnectionAge($server); + $maxAge = config('constants.ssh.mux_max_age'); + + return $connectionAge !== null && $connectionAge > $maxAge; + } + + /** + * Get the age of the current connection in seconds + */ + public static function getConnectionAge(Server $server): ?int + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + $connectionTime = Cache::get($cacheKey); + + if ($connectionTime === null) { + return null; + } + + return time() - $connectionTime; + } + + /** + * Refresh a multiplexed connection by closing and re-establishing it + */ + public static function refreshMultiplexedConnection(Server $server): bool + { + // Close existing connection + self::removeMuxFile($server); + + // Establish new connection + return self::establishNewMultiplexedConnection($server); + } + + /** + * Store connection metadata when a new connection is established + */ + private static function storeConnectionMetadata(Server $server): void + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time + } + + /** + * Clear connection metadata from cache + */ + private static function clearConnectionMetadata(Server $server): void + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + Cache::forget($cacheKey); + } } diff --git a/app/Helpers/SshRetryHandler.php b/app/Helpers/SshRetryHandler.php new file mode 100644 index 000000000..aaaf4252a --- /dev/null +++ b/app/Helpers/SshRetryHandler.php @@ -0,0 +1,34 @@ +executeWithSshRetry($callback, $context, $throwError); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 10d58ecec..e9c52d2f5 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -15,6 +15,9 @@ use App\Models\Project; use App\Models\Server; use App\Models\Service; +use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; +use App\Services\DockerImageParser; use Illuminate\Http\Request; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; @@ -187,6 +190,8 @@ public function applications(Request $request) 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -214,6 +219,35 @@ public function applications(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_public_application(Request $request) @@ -306,6 +340,8 @@ public function create_public_application(Request $request) 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -333,6 +369,35 @@ public function create_public_application(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_private_gh_app_application(Request $request) @@ -425,6 +490,8 @@ public function create_private_gh_app_application(Request $request) 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -452,6 +519,35 @@ public function create_private_gh_app_application(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_private_deploy_key_application(Request $request) @@ -528,6 +624,8 @@ public function create_private_deploy_key_application(Request $request) 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -555,6 +653,35 @@ public function create_private_deploy_key_application(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_dockerfile_application(Request $request) @@ -628,6 +755,8 @@ public function create_dockerfile_application(Request $request) 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -655,6 +784,35 @@ public function create_dockerfile_application(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_dockerimage_application(Request $request) @@ -691,6 +849,8 @@ public function create_dockerimage_application(Request $request) 'description' => ['type' => 'string', 'description' => 'The application description.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -718,6 +878,35 @@ public function create_dockerimage_application(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_dockercompose_application(Request $request) @@ -732,11 +921,13 @@ private function create_application(Request $request, $type) return invalidTokenResponse(); } + $this->authorize('create', Application::class); + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -777,6 +968,7 @@ private function create_application(Request $request, $type) $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; $isStatic = $request->is_static; + $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; if (! is_null($customNginxConfiguration)) { @@ -824,8 +1016,8 @@ private function create_application(Request $request, $type) $destination = $destinations->first(); if ($type === 'public') { $validationRules = [ - 'git_repository' => 'string|required', - 'git_branch' => 'string|required', + 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], + 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'docker_compose_location' => 'string', @@ -876,7 +1068,7 @@ private function create_application(Request $request, $type) $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; } - $application->git_repository = $repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2); + $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString(); $application->fqdn = $fqdn; $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); @@ -886,6 +1078,10 @@ private function create_application(Request $request, $type) $application->settings->is_static = $isStatic; $application->settings->save(); } + if (isset($connectToDockerNetwork)) { + $application->settings->connect_to_docker_network = $connectToDockerNetwork; + $application->settings->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -924,7 +1120,7 @@ private function create_application(Request $request, $type) } elseif ($type === 'private-gh-app') { $validationRules = [ 'git_repository' => 'string|required', - 'git_branch' => 'string|required', + 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', @@ -1032,7 +1228,7 @@ private function create_application(Request $request, $type) $application->docker_compose_domains = $dockerComposeDomainsJson; } $application->fqdn = $fqdn; - $application->git_repository = $gitRepository; + $application->git_repository = str($gitRepository)->trim()->toString(); $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; @@ -1079,8 +1275,8 @@ private function create_application(Request $request, $type) } elseif ($type === 'private-deploy-key') { $validationRules = [ - 'git_repository' => 'string|required', - 'git_branch' => 'string|required', + 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], + 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', @@ -1317,9 +1513,33 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - if (! $request->docker_registry_image_tag) { - $request->offsetSet('docker_registry_image_tag', 'latest'); + // Process docker image name and tag using DockerImageParser + $dockerImageName = $request->docker_registry_image_name; + $dockerImageTag = $request->docker_registry_image_tag; + + // Build the full Docker image string for parsing + if ($dockerImageTag) { + $dockerImageString = $dockerImageName.':'.$dockerImageTag; + } else { + $dockerImageString = $dockerImageName; } + + // Parse using DockerImageParser to normalize the image reference + $parser = new DockerImageParser; + $parser->parse($dockerImageString); + + // Get normalized image name and tag + $normalizedImageName = $parser->getFullImageNameWithoutTag(); + + // Append @sha256 to image name if using digest + if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) { + $normalizedImageName .= '@sha256'; + } + + // Set processed values back to request + $request->offsetSet('docker_registry_image_name', $normalizedImageName); + $request->offsetSet('docker_registry_image_tag', $parser->getTag()); + $application = new Application; removeUnnecessaryFieldsFromRequest($request); @@ -1365,7 +1585,7 @@ private function create_application(Request $request, $type) 'domains' => data_get($application, 'domains'), ]))->setStatusCode(201); } elseif ($type === 'dockercompose') { - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -1508,6 +1728,8 @@ public function application_by_uuid(Request $request) return response()->json(['message' => 'Application not found.'], 404); } + $this->authorize('view', $application); + return response()->json($this->removeSensitiveData($application)); } @@ -1686,12 +1908,14 @@ public function delete_by_uuid(Request $request) ], 404); } + $this->authorize('delete', $application); + DeleteResourceJob::dispatch( resource: $application, - deleteConfigurations: $request->query->get('delete_configurations', true), deleteVolumes: $request->query->get('delete_volumes', true), - dockerCleanup: $request->query->get('docker_cleanup', true), - deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true), + deleteConfigurations: $request->query->get('delete_configurations', true), + dockerCleanup: $request->query->get('docker_cleanup', true) ); return response()->json([ @@ -1790,6 +2014,8 @@ public function delete_by_uuid(Request $request) 'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -1823,6 +2049,35 @@ public function delete_by_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function update_by_uuid(Request $request) @@ -1842,8 +2097,11 @@ public function update_by_uuid(Request $request) 'message' => 'Application not found', ], 404); } + + $this->authorize('update', $application); + $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password']; + $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override']; $validationRules = [ 'name' => 'string|max:255', @@ -1959,14 +2217,23 @@ public function update_by_uuid(Request $request) 'errors' => $errors, ], 422); } - if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { + // Check for domain conflicts + $result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid); + if (isset($result['error'])) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'domains' => 'One of the domain is already used.', - ], + 'errors' => ['domains' => $result['error']], ], 422); } + + // If there are conflicts and force is not enabled, return warning + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } } $dockerComposeDomainsJson = collect(); @@ -2013,6 +2280,7 @@ public function update_by_uuid(Request $request) } $instantDeploy = $request->instant_deploy; $isStatic = $request->is_static; + $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; if (isset($useBuildServer)) { @@ -2025,6 +2293,11 @@ public function update_by_uuid(Request $request) $application->settings->save(); } + if (isset($connectToDockerNetwork)) { + $application->settings->connect_to_docker_network = $connectToDockerNetwork; + $application->settings->save(); + } + removeUnnecessaryFieldsFromRequest($request); $data = $request->all(); @@ -2036,6 +2309,9 @@ public function update_by_uuid(Request $request) data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); } $application->fill($data); + if ($application->settings->is_container_label_readonly_enabled && $requestHasDomains && $server->isProxyShouldRun()) { + $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); + } $application->save(); if ($instantDeploy) { @@ -2120,6 +2396,9 @@ public function envs(Request $request) 'message' => 'Application not found', ], 404); } + + $this->authorize('view', $application); + $envs = $application->environment_variables->sortBy('id')->merge($application->environment_variables_preview->sortBy('id')); $envs = $envs->map(function ($env) { @@ -2175,7 +2454,6 @@ public function envs(Request $request) 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2216,7 +2494,7 @@ public function envs(Request $request) )] public function update_env_by_uuid(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2234,14 +2512,18 @@ public function update_env_by_uuid(Request $request) 'message' => 'Application not found', ], 404); } + + $this->authorize('manageEnvironment', $application); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2259,16 +2541,12 @@ public function update_env_by_uuid(Request $request) ], 422); } $is_preview = $request->is_preview ?? false; - $is_build_time = $request->is_build_time ?? false; $is_literal = $request->is_literal ?? false; $key = str($request->key)->trim()->replace(' ', '_')->value; if ($is_preview) { $env = $application->environment_variables_preview->where('key', $key)->first(); if ($env) { $env->value = $request->value; - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2281,6 +2559,12 @@ public function update_env_by_uuid(Request $request) if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2293,9 +2577,6 @@ public function update_env_by_uuid(Request $request) $env = $application->environment_variables->where('key', $key)->first(); if ($env) { $env->value = $request->value; - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2308,6 +2589,12 @@ public function update_env_by_uuid(Request $request) if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2362,7 +2649,6 @@ public function update_env_by_uuid(Request $request) 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2424,6 +2710,8 @@ public function create_bulk_envs(Request $request) ], 404); } + $this->authorize('manageEnvironment', $application); + $bulk_data = $request->get('data'); if (! $bulk_data) { return response()->json([ @@ -2431,7 +2719,7 @@ public function create_bulk_envs(Request $request) ], 400); } $bulk_data = collect($bulk_data)->map(function ($item) { - return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']); + return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']); }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { @@ -2439,10 +2727,11 @@ public function create_bulk_envs(Request $request) 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', ]); if ($validator->fails()) { return response()->json([ @@ -2451,7 +2740,6 @@ public function create_bulk_envs(Request $request) ], 422); } $is_preview = $item->get('is_preview') ?? false; - $is_build_time = $item->get('is_build_time') ?? false; $is_literal = $item->get('is_literal') ?? false; $is_multi_line = $item->get('is_multiline') ?? false; $is_shown_once = $item->get('is_shown_once') ?? false; @@ -2460,9 +2748,7 @@ public function create_bulk_envs(Request $request) $env = $application->environment_variables_preview->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } + if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2472,16 +2758,23 @@ public function create_bulk_envs(Request $request) if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); + } $env->save(); } else { $env = $application->environment_variables()->create([ 'key' => $item->get('key'), 'value' => $item->get('value'), 'is_preview' => $is_preview, - 'is_build_time' => $is_build_time, 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2490,9 +2783,6 @@ public function create_bulk_envs(Request $request) $env = $application->environment_variables->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2502,16 +2792,23 @@ public function create_bulk_envs(Request $request) if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); + } $env->save(); } else { $env = $application->environment_variables()->create([ 'key' => $item->get('key'), 'value' => $item->get('value'), 'is_preview' => $is_preview, - 'is_build_time' => $is_build_time, 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2555,7 +2852,6 @@ public function create_bulk_envs(Request $request) 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2595,7 +2891,7 @@ public function create_bulk_envs(Request $request) )] public function create_env(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2608,14 +2904,18 @@ public function create_env(Request $request) 'message' => 'Application not found', ], 404); } + + $this->authorize('manageEnvironment', $application); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2646,10 +2946,11 @@ public function create_env(Request $request) 'key' => $request->key, 'value' => $request->value, 'is_preview' => $request->is_preview ?? false, - 'is_build_time' => $request->is_build_time ?? false, 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2669,10 +2970,11 @@ public function create_env(Request $request) 'key' => $request->key, 'value' => $request->value, 'is_preview' => $request->is_preview ?? false, - 'is_build_time' => $request->is_build_time ?? false, 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2758,6 +3060,9 @@ public function delete_env_by_uuid(Request $request) 'message' => 'Application not found.', ], 404); } + + $this->authorize('manageEnvironment', $application); + $found_env = EnvironmentVariable::where('uuid', $request->env_uuid) ->where('resourceable_type', Application::class) ->where('resourceable_id', $application->id) @@ -2861,6 +3166,8 @@ public function action_deploy(Request $request) return response()->json(['message' => 'Application not found.'], 404); } + $this->authorize('deploy', $application); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( @@ -2953,6 +3260,9 @@ public function action_stop(Request $request) if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } + + $this->authorize('deploy', $application); + StopApplication::dispatch($application); return response()->json( @@ -3030,6 +3340,8 @@ public function action_restart(Request $request) return response()->json(['message' => 'Application not found.'], 404); } + $this->authorize('deploy', $application); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( @@ -3052,131 +3364,6 @@ public function action_restart(Request $request) ); } - // #[OA\Post( - // summary: 'Execute Command', - // description: "Execute a command on the application's current container.", - // path: '/applications/{uuid}/execute', - // operationId: 'execute-command-application', - // security: [ - // ['bearerAuth' => []], - // ], - // tags: ['Applications'], - // parameters: [ - // new OA\Parameter( - // name: 'uuid', - // in: 'path', - // description: 'UUID of the application.', - // required: true, - // schema: new OA\Schema( - // type: 'string', - // format: 'uuid', - // ) - // ), - // ], - // requestBody: new OA\RequestBody( - // required: true, - // description: 'Command to execute.', - // content: new OA\MediaType( - // mediaType: 'application/json', - // schema: new OA\Schema( - // type: 'object', - // properties: [ - // 'command' => ['type' => 'string', 'description' => 'Command to execute.'], - // ], - // ), - // ), - // ), - // responses: [ - // new OA\Response( - // response: 200, - // description: "Execute a command on the application's current container.", - // content: [ - // new OA\MediaType( - // mediaType: 'application/json', - // schema: new OA\Schema( - // type: 'object', - // properties: [ - // 'message' => ['type' => 'string', 'example' => 'Command executed.'], - // 'response' => ['type' => 'string'], - // ] - // ) - // ), - // ] - // ), - // new OA\Response( - // response: 401, - // ref: '#/components/responses/401', - // ), - // new OA\Response( - // response: 400, - // ref: '#/components/responses/400', - // ), - // new OA\Response( - // response: 404, - // ref: '#/components/responses/404', - // ), - // ] - // )] - // public function execute_command_by_uuid(Request $request) - // { - // // TODO: Need to review this from security perspective, to not allow arbitrary command execution - // $allowedFields = ['command']; - // $teamId = getTeamIdFromToken(); - // if (is_null($teamId)) { - // return invalidTokenResponse(); - // } - // $uuid = $request->route('uuid'); - // if (! $uuid) { - // return response()->json(['message' => 'UUID is required.'], 400); - // } - // $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); - // if (! $application) { - // return response()->json(['message' => 'Application not found.'], 404); - // } - // $return = validateIncomingRequest($request); - // if ($return instanceof \Illuminate\Http\JsonResponse) { - // return $return; - // } - // $validator = customApiValidator($request->all(), [ - // 'command' => 'string|required', - // ]); - - // $extraFields = array_diff(array_keys($request->all()), $allowedFields); - // if ($validator->fails() || ! empty($extraFields)) { - // $errors = $validator->errors(); - // if (! empty($extraFields)) { - // foreach ($extraFields as $field) { - // $errors->add($field, 'This field is not allowed.'); - // } - // } - - // return response()->json([ - // 'message' => 'Validation failed.', - // 'errors' => $errors, - // ], 422); - // } - - // $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail(); - // $status = getContainerStatus($application->destination->server, $container['Names']); - - // if ($status !== 'running') { - // return response()->json([ - // 'message' => 'Application is not running.', - // ], 400); - // } - - // $commands = collect([ - // executeInDocker($container['Names'], $request->command), - // ]); - - // $res = instant_remote_process(command: $commands, server: $application->destination->server); - - // return response()->json([ - // 'message' => 'Command executed.', - // 'response' => $res, - // ]); - // } - private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); @@ -3224,11 +3411,12 @@ private function validateDataApplications(Request $request, Server $server) $fqdn = str($fqdn)->replaceStart(',', '')->trim(); $errors = []; $fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) { + $domain = trim($domain); if (filter_var($domain, FILTER_VALIDATE_URL) === false) { $errors[] = 'Invalid domain: '.$domain; } - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); if (count($errors) > 0) { return response()->json([ @@ -3236,14 +3424,23 @@ private function validateDataApplications(Request $request, Server $server) 'errors' => $errors, ], 422); } - if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { + // Check for domain conflicts + $result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid); + if (isset($result['error'])) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'domains' => 'One of the domain is already used.', - ], + 'errors' => ['domains' => $result['error']], ], 422); } + + // If there are conflicts and force is not enabled, return warning + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } } } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 504665f6a..46282fddb 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -9,10 +9,15 @@ use App\Actions\Database\StopDatabaseProxy; use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; +use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; use App\Models\Project; +use App\Models\S3Storage; +use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandalonePostgresql; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; class DatabasesController extends Controller @@ -78,13 +83,88 @@ public function databases(Request $request) foreach ($projects as $project) { $databases = $databases->merge($project->databases()); } - $databases = $databases->map(function ($database) { + + $databaseIds = $databases->pluck('id')->toArray(); + + $backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log') + ->whereIn('database_id', $databaseIds) + ->get() + ->groupBy('database_id'); + + $databases = $databases->map(function ($database) use ($backupConfigs) { + $database->backup_configs = $backupConfigs->get($database->id, collect())->values(); + return $this->removeSensitiveData($database); }); return response()->json($databases); } + #[OA\Get( + summary: 'Get', + description: 'Get backups details by database UUID.', + path: '/databases/{uuid}/backups', + operationId: 'get-database-backups-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all backups for a database', + content: new OA\JsonContent( + type: 'string', + example: 'Content is very complex. Will be implemented later.', + ), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function database_backup_details_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get(); + + return response()->json($backupConfig); + } + #[OA\Get( summary: 'Get', description: 'Get database by UUID.', @@ -143,6 +223,8 @@ public function database_by_uuid(Request $request) return response()->json(['message' => 'Database not found.'], 404); } + $this->authorize('view', $database); + return response()->json($this->removeSensitiveData($database)); } @@ -235,6 +317,10 @@ public function database_by_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function update_by_uuid(Request $request) @@ -245,6 +331,7 @@ public function update_by_uuid(Request $request) return invalidTokenResponse(); } + // this check if the request is a valid json $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -276,6 +363,9 @@ public function update_by_uuid(Request $request) if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('update', $database); + if ($request->is_public && $request->public_port) { if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) { return response()->json(['message' => 'Public port already used by another database.'], 400); @@ -493,7 +583,8 @@ public function update_by_uuid(Request $request) $whatToDoWithDatabaseProxy = 'start'; } - $database->update($request->all()); + // Only update database fields, not backup configuration + $database->update($request->only($allowedFields)); if ($whatToDoWithDatabaseProxy === 'start') { StartDatabaseProxy::dispatch($database); @@ -506,6 +597,419 @@ public function update_by_uuid(Request $request) ]); } + #[OA\Post( + summary: 'Create Backup', + description: 'Create a new scheduled backup configuration for a database', + path: '/databases/{uuid}/backups', + operationId: 'create-database-backup', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Backup configuration data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['frequency'], + properties: [ + 'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'], + 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true], + 'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'], + 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], + 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], + 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], + 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], + 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Backup configuration created successfully', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'format' => 'uuid', 'example' => '550e8400-e29b-41d4-a716-446655440000'], + 'message' => ['type' => 'string', 'example' => 'Backup configuration created successfully.'], + ] + ) + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_backup(Request $request) + { + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate incoming request is valid JSON + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'frequency' => 'required|string', + 'enabled' => 'boolean', + 'save_s3' => 'boolean', + 'dump_all' => 'boolean', + 'backup_now' => 'boolean|nullable', + 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 'databases_to_backup' => 'string|nullable', + 'database_backup_retention_amount_locally' => 'integer|min:0', + 'database_backup_retention_days_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_amount_s3' => 'integer|min:0', + 'database_backup_retention_days_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'integer|min:0', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + + $uuid = $request->uuid; + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageBackups', $database); + + // Validate frequency is a valid cron expression + $isValid = validate_cron_expression($request->frequency); + if (! $isValid) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + // Validate S3 storage if save_s3 is true + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], + ], 422); + } + + if ($request->filled('s3_storage_uuid')) { + $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + if (! $existsInTeam) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + } + + // Check for extra fields + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); + if (! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $backupData = $request->only($backupConfigFields); + + // Convert s3_storage_uuid to s3_storage_id + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } elseif ($request->boolean('save_s3')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + unset($backupData['s3_storage_uuid']); + } + + // Set default databases_to_backup based on database type if not provided + if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) { + if ($database->type() === 'standalone-postgresql') { + $backupData['databases_to_backup'] = $database->postgres_db; + } elseif ($database->type() === 'standalone-mysql') { + $backupData['databases_to_backup'] = $database->mysql_database; + } elseif ($database->type() === 'standalone-mariadb') { + $backupData['databases_to_backup'] = $database->mariadb_database; + } + } + + // Add required fields + $backupData['database_id'] = $database->id; + $backupData['database_type'] = $database->getMorphClass(); + $backupData['team_id'] = $teamId; + + // Set defaults + if (! isset($backupData['enabled'])) { + $backupData['enabled'] = true; + } + + $backupConfig = ScheduledDatabaseBackup::create($backupData); + + // Trigger immediate backup if requested + if ($request->backup_now) { + dispatch(new DatabaseBackupJob($backupConfig)); + } + + return response()->json([ + 'uuid' => $backupConfig->uuid, + 'message' => 'Backup configuration created successfully.', + ], 201); + } + + #[OA\Patch( + summary: 'Update', + description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}', + operationId: 'update-database-backup', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + description: 'UUID of the backup configuration.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Database backup configuration data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], + 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], + 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'], + 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], + 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], + 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], + 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database backup configuration updated', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_backup(Request $request) + { + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + // this check if the request is a valid json + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = customApiValidator($request->all(), [ + 'save_s3' => 'boolean', + 'backup_now' => 'boolean|nullable', + 'enabled' => 'boolean', + 'dump_all' => 'boolean', + 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 'databases_to_backup' => 'string|nullable', + 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'database_backup_retention_amount_locally' => 'integer|min:0', + 'database_backup_retention_days_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_amount_s3' => 'integer|min:0', + 'database_backup_retention_days_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'integer|min:0', + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $uuid = $request->uuid; + removeUnnecessaryFieldsFromRequest($request); + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], + ], 422); + } + if ($request->filled('s3_storage_uuid')) { + $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + if (! $existsInTeam) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + } + + $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + if (! $backupConfig) { + return response()->json(['message' => 'Backup config not found.'], 404); + } + + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); + if (! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $backupData = $request->only($backupConfigFields); + + // Convert s3_storage_uuid to s3_storage_id + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } elseif ($request->boolean('save_s3')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + unset($backupData['s3_storage_uuid']); + } + + $backupConfig->update($backupData); + + if ($request->backup_now) { + dispatch(new DatabaseBackupJob($backupConfig)); + } + + return response()->json([ + 'message' => 'Database backup configuration updated', + ]); + } + #[OA\Post( summary: 'Create (PostgreSQL)', description: 'Create a new PostgreSQL database.', @@ -566,6 +1070,10 @@ public function update_by_uuid(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_postgresql(Request $request) @@ -629,6 +1137,10 @@ public function create_database_postgresql(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_clickhouse(Request $request) @@ -691,6 +1203,10 @@ public function create_database_clickhouse(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_dragonfly(Request $request) @@ -754,6 +1270,10 @@ public function create_database_dragonfly(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_redis(Request $request) @@ -817,6 +1337,10 @@ public function create_database_redis(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_keydb(Request $request) @@ -883,6 +1407,10 @@ public function create_database_keydb(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_mariadb(Request $request) @@ -949,6 +1477,10 @@ public function create_database_mariadb(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_mysql(Request $request) @@ -1012,6 +1544,10 @@ public function create_database_mysql(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_database_mongodb(Request $request) @@ -1028,6 +1564,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) return invalidTokenResponse(); } + // Use a generic authorization for database creation - using PostgreSQL as representative model + $this->authorize('create', StandalonePostgresql::class); + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -1606,12 +2145,14 @@ public function delete_by_uuid(Request $request) return response()->json(['message' => 'Database not found.'], 404); } + $this->authorize('delete', $database); + DeleteResourceJob::dispatch( resource: $database, - deleteConfigurations: $request->query->get('delete_configurations', true), deleteVolumes: $request->query->get('delete_volumes', true), - dockerCleanup: $request->query->get('docker_cleanup', true), - deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true), + deleteConfigurations: $request->query->get('delete_configurations', true), + dockerCleanup: $request->query->get('docker_cleanup', true) ); return response()->json([ @@ -1619,6 +2160,345 @@ public function delete_by_uuid(Request $request) ]); } + #[OA\Delete( + summary: 'Delete backup configuration', + description: 'Deletes a backup configuration and all its executions.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}', + operationId: 'delete-backup-configuration-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration to delete', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'delete_s3', + in: 'query', + required: false, + description: 'Whether to delete all backup files from S3', + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Backup configuration deleted.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Backup configuration and all executions deleted.'), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup configuration not found.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Backup configuration not found.'), + ] + ) + ), + ] + )] + public function delete_backup_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); + + try { + DB::beginTransaction(); + // Get all executions for this backup configuration + $executions = $backup->executions()->get(); + + // Delete all execution files (locally and optionally from S3) + foreach ($executions as $execution) { + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $database->destination->server); + + if ($deleteS3 && $backup->s3) { + deleteBackupsS3($execution->filename, $backup->s3); + } + } + + $execution->delete(); + } + + // Delete the backup configuration itself + $backup->delete(); + DB::commit(); + + return response()->json([ + 'message' => 'Backup configuration and all executions deleted.', + ]); + } catch (\Exception $e) { + DB::rollBack(); + + return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + } + } + + #[OA\Delete( + summary: 'Delete backup execution', + description: 'Deletes a specific backup execution.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', + operationId: 'delete-backup-execution-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'execution_uuid', + in: 'path', + required: true, + description: 'UUID of the backup execution to delete', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'delete_s3', + in: 'query', + required: false, + description: 'Whether to delete the backup from S3', + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Backup execution deleted.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Backup execution deleted.'), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup execution not found.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Backup execution not found.'), + ] + ) + ), + ] + )] + public function delete_execution_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate parameters + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + if (! $request->execution_uuid) { + return response()->json(['message' => 'Execution UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + // Find the specific execution + $execution = $backup->executions()->where('uuid', $request->execution_uuid)->first(); + if (! $execution) { + return response()->json(['message' => 'Backup execution not found.'], 404); + } + + $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); + + try { + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $database->destination->server); + + if ($deleteS3 && $backup->s3) { + deleteBackupsS3($execution->filename, $backup->s3); + } + } + + $execution->delete(); + + return response()->json([ + 'message' => 'Backup execution deleted.', + ]); + } catch (\Exception $e) { + return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); + } + } + + #[OA\Get( + summary: 'List backup executions', + description: 'Get all executions for a specific backup configuration.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', + operationId: 'list-backup-executions', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of backup executions', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property( + property: 'executions', + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'filename', type: 'string'), + new OA\Property(property: 'size', type: 'integer'), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + new OA\Property(property: 'status', type: 'string'), + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup configuration not found.', + ), + ] + )] + public function list_backup_executions(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + // Get all executions for this backup configuration + $executions = $backup->executions() + ->orderBy('created_at', 'desc') + ->get() + ->map(function ($execution) { + return [ + 'uuid' => $execution->uuid, + 'filename' => $execution->filename, + 'size' => $execution->size, + 'created_at' => $execution->created_at->toIso8601String(), + 'message' => $execution->message, + 'status' => $execution->status, + ]; + }); + + return response()->json([ + 'executions' => $executions, + ]); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', @@ -1684,6 +2564,9 @@ public function action_deploy(Request $request) if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('manage', $database); + if (str($database->status)->contains('running')) { return response()->json(['message' => 'Database is already running.'], 400); } @@ -1762,6 +2645,9 @@ public function action_stop(Request $request) if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('manage', $database); + if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { return response()->json(['message' => 'Database is already stopped.'], 400); } @@ -1840,6 +2726,9 @@ public function action_restart(Request $request) if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('manage', $database); + RestartDatabase::dispatch($database); return response()->json( diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 5c7f20902..16a7b6f71 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -131,6 +131,161 @@ public function deployment_by_uuid(Request $request) return response()->json($this->removeSensitiveData($deployment)); } + #[OA\Post( + summary: 'Cancel', + description: 'Cancel a deployment by UUID.', + path: '/deployments/{uuid}/cancel', + operationId: 'cancel-deployment-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Deployments'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deployment cancelled successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Deployment cancelled successfully.'], + 'deployment_uuid' => ['type' => 'string', 'example' => 'cm37r6cqj000008jm0veg5tkm'], + 'status' => ['type' => 'string', 'example' => 'cancelled-by-user'], + ] + ) + ), + ]), + new OA\Response( + response: 400, + description: 'Deployment cannot be cancelled (already finished/failed/cancelled).', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Deployment cannot be cancelled. Current status: finished'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + description: 'User doesn\'t have permission to cancel this deployment.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'You do not have permission to cancel this deployment.'], + ] + ) + ), + ]), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function cancel_deployment(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + + // Find the deployment by UUID + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first(); + if (! $deployment) { + return response()->json(['message' => 'Deployment not found.'], 404); + } + + // Check if the deployment belongs to the user's team + $servers = Server::whereTeamId($teamId)->pluck('id'); + if (! $servers->contains($deployment->server_id)) { + return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403); + } + + // Check if deployment can be cancelled (must be queued or in_progress) + $cancellableStatuses = [ + \App\Enums\ApplicationDeploymentStatus::QUEUED->value, + \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + ]; + + if (! in_array($deployment->status, $cancellableStatuses)) { + return response()->json([ + 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", + ], 400); + } + + // Perform the cancellation + try { + $deployment_uuid = $deployment->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; + $build_server_id = $deployment->build_server_id ?? $deployment->server_id; + + // Mark deployment as cancelled + $deployment->update([ + 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + + // Get the server + $server = Server::find($build_server_id); + + if ($server) { + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); + + // Check if container exists and kill it + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process([$kill_command], $server); + $deployment->addLogEntry('Deployment container stopped.'); + } else { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } + + // Kill running process if process ID exists + if ($deployment->current_process_id) { + try { + $processKillCommand = "kill -9 {$deployment->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone + } + } + } + + return response()->json([ + 'message' => 'Deployment cancelled successfully.', + 'deployment_uuid' => $deployment->deployment_uuid, + 'status' => $deployment->status, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Failed to cancel deployment: '.$e->getMessage(), + ], 500); + } + } + #[OA\Get( summary: 'Deploy', description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.', @@ -225,6 +380,14 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p foreach ($uuids as $uuid) { $resource = getResourceByUuid($uuid, $teamId); if ($resource) { + if ($pr !== 0) { + $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + if (! $preview) { + $deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]); + + continue; + } + } ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr); if ($deployment_uuid) { $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); @@ -299,6 +462,12 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar } switch ($resource?->getMorphClass()) { case Application::class: + // Check authorization for application deployment + try { + $this->authorize('deploy', $resource); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null]; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $resource, @@ -313,11 +482,22 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar } break; case Service::class: + // Check authorization for service deployment + try { + $this->authorize('deploy', $resource); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null]; + } StartService::run($resource); $message = "Service {$resource->name} started. It could take a while, be patient."; break; default: - // Database resource + // Database resource - check authorization + try { + $this->authorize('manage', $resource); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null]; + } StartDatabase::dispatch($resource); $resource->started_at ??= now(); @@ -423,6 +603,10 @@ public function get_application_deployments(Request $request) if (is_null($application)) { return response()->json(['message' => 'Application not found'], 404); } + + // Check authorization to view application deployments + $this->authorize('view', $application); + $deployments = $application->deployments($skip, $take); return response()->json($deployments); diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php new file mode 100644 index 000000000..f6a6b3513 --- /dev/null +++ b/app/Http/Controllers/Api/GithubController.php @@ -0,0 +1,745 @@ +makeHidden([ + 'client_secret', + 'webhook_secret', + ]); + + return serializeApiResponse($githubApp); + } + + #[OA\Get( + summary: 'List', + description: 'List all GitHub apps.', + path: '/github-apps', + operationId: 'list-github-apps', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + responses: [ + new OA\Response( + response: 200, + description: 'List of GitHub apps.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'organization' => ['type' => 'string', 'nullable' => true], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'app_id' => ['type' => 'integer'], + 'installation_id' => ['type' => 'integer'], + 'client_id' => ['type' => 'string'], + 'private_key_id' => ['type' => 'integer'], + 'is_system_wide' => ['type' => 'boolean'], + 'is_public' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + 'type' => ['type' => 'string'], + ] + ) + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function list_github_apps(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $githubApps = GithubApp::where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + })->get(); + + $githubApps = $githubApps->map(function ($app) { + return $this->removeSensitiveData($app); + }); + + return response()->json($githubApps); + } + + #[OA\Post( + summary: 'Create GitHub App', + description: 'Create a new GitHub app.', + path: '/github-apps', + operationId: 'create-github-app', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + requestBody: new OA\RequestBody( + description: 'GitHub app creation payload.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'], + 'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'], + 'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'], + 'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'], + 'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'], + 'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'], + 'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'], + 'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'], + 'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'], + 'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'], + ], + required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'GitHub app created successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'organization' => ['type' => 'string', 'nullable' => true], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'app_id' => ['type' => 'integer'], + 'installation_id' => ['type' => 'integer'], + 'client_id' => ['type' => 'string'], + 'private_key_id' => ['type' => 'integer'], + 'is_system_wide' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_github_app(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $allowedFields = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_user', + 'custom_port', + 'app_id', + 'installation_id', + 'client_id', + 'client_secret', + 'webhook_secret', + 'private_key_uuid', + 'is_system_wide', + ]; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'organization' => 'nullable|string|max:255', + 'api_url' => 'required|string|url', + 'html_url' => 'required|string|url', + 'custom_user' => 'nullable|string|max:255', + 'custom_port' => 'nullable|integer|min:1|max:65535', + 'app_id' => 'required|integer', + 'installation_id' => 'required|integer', + 'client_id' => 'required|string|max:255', + 'client_secret' => 'required|string', + 'webhook_secret' => 'required|string', + 'private_key_uuid' => 'required|string', + 'is_system_wide' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + try { + // Verify the private key belongs to the team + $privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid')) + ->where('team_id', $teamId) + ->first(); + + if (! $privateKey) { + return response()->json([ + 'message' => 'Private key not found or does not belong to your team.', + ], 404); + } + + $payload = [ + 'uuid' => Str::uuid(), + 'name' => $request->input('name'), + 'organization' => $request->input('organization'), + 'api_url' => $request->input('api_url'), + 'html_url' => $request->input('html_url'), + 'custom_user' => $request->input('custom_user', 'git'), + 'custom_port' => $request->input('custom_port', 22), + 'app_id' => $request->input('app_id'), + 'installation_id' => $request->input('installation_id'), + 'client_id' => $request->input('client_id'), + 'client_secret' => $request->input('client_secret'), + 'webhook_secret' => $request->input('webhook_secret'), + 'private_key_id' => $privateKey->id, + 'is_public' => false, + 'team_id' => $teamId, + ]; + + if (! isCloud()) { + $payload['is_system_wide'] = $request->input('is_system_wide', false); + } + + $githubApp = GithubApp::create($payload); + + return response()->json($githubApp, 201); + } catch (\Throwable $e) { + return handleError($e); + } + } + + #[OA\Get( + path: '/github-apps/{github_app_id}/repositories', + summary: 'Load Repositories for a GitHub App', + description: 'Fetch repositories from GitHub for a given GitHub app.', + operationId: 'load-repositories', + tags: ['GitHub Apps'], + security: [ + ['bearerAuth' => []], + ], + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Repositories loaded successfully.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + new OA\Property( + property: 'repositories', + type: 'array', + items: new OA\Items(type: 'object') + ), + ] + ) + ) + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function load_repositories($github_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + $token = generateGithubInstallationToken($githubApp); + $repositories = collect(); + $page = 1; + $maxPages = 100; // Safety limit: max 10,000 repositories + + while ($page <= $maxPages) { + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/installation/repositories', [ + 'per_page' => 100, + 'page' => $page, + ]); + + if ($response->status() !== 200) { + return response()->json([ + 'message' => $response->json()['message'] ?? 'Failed to load repositories', + ], $response->status()); + } + + $json = $response->json(); + $repos = $json['repositories'] ?? []; + + if (empty($repos)) { + break; // No more repositories to load + } + + $repositories = $repositories->concat($repos); + $page++; + } + + return response()->json([ + 'repositories' => $repositories->sortBy('name')->values(), + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json(['message' => 'GitHub app not found'], 404); + } catch (\Throwable $e) { + return handleError($e); + } + } + + #[OA\Get( + path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', + summary: 'Load Branches for a GitHub Repository', + description: 'Fetch branches from GitHub for a given repository.', + operationId: 'load-branches', + tags: ['GitHub Apps'], + security: [ + ['bearerAuth' => []], + ], + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + new OA\Parameter( + name: 'owner', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + description: 'Repository owner' + ), + new OA\Parameter( + name: 'repo', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + description: 'Repository name' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Branches loaded successfully.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + new OA\Property( + property: 'branches', + type: 'array', + items: new OA\Items(type: 'object') + ), + ] + ) + ) + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function load_branches($github_app_id, $owner, $repo) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + $token = generateGithubInstallationToken($githubApp); + + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/repos/{$owner}/{$repo}/branches"); + + if ($response->status() !== 200) { + return response()->json([ + 'message' => 'Error loading branches from GitHub.', + 'error' => $response->json('message'), + ], $response->status()); + } + + $branches = $response->json(); + + return response()->json([ + 'branches' => $branches, + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json(['message' => 'GitHub app not found'], 404); + } catch (\Throwable $e) { + return handleError($e); + } + } + + /** + * Update a GitHub app. + */ + #[OA\Patch( + path: '/github-apps/{github_app_id}', + operationId: 'updateGithubApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + summary: 'Update GitHub App', + description: 'Update an existing GitHub app.', + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'GitHub App name'], + 'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'], + 'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'], + 'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'], + 'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'], + 'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'], + 'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'], + 'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'], + 'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'], + 'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'], + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'GitHub app updated successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'], + 'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitHub app not found'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_github_app(Request $request, $github_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + // Define allowed fields for update + $allowedFields = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_user', + 'custom_port', + 'app_id', + 'installation_id', + 'client_id', + 'client_secret', + 'webhook_secret', + 'private_key_uuid', + ]; + + if (! isCloud()) { + $allowedFields[] = 'is_system_wide'; + } + + $payload = $request->only($allowedFields); + + // Validate the request + $rules = []; + if (isset($payload['name'])) { + $rules['name'] = 'string'; + } + if (isset($payload['organization'])) { + $rules['organization'] = 'nullable|string'; + } + if (isset($payload['api_url'])) { + $rules['api_url'] = 'url'; + } + if (isset($payload['html_url'])) { + $rules['html_url'] = 'url'; + } + if (isset($payload['custom_user'])) { + $rules['custom_user'] = 'string'; + } + if (isset($payload['custom_port'])) { + $rules['custom_port'] = 'integer|min:1|max:65535'; + } + if (isset($payload['app_id'])) { + $rules['app_id'] = 'integer'; + } + if (isset($payload['installation_id'])) { + $rules['installation_id'] = 'integer'; + } + if (isset($payload['client_id'])) { + $rules['client_id'] = 'string'; + } + if (isset($payload['client_secret'])) { + $rules['client_secret'] = 'string'; + } + if (isset($payload['webhook_secret'])) { + $rules['webhook_secret'] = 'string'; + } + if (isset($payload['private_key_uuid'])) { + $rules['private_key_uuid'] = 'string|uuid'; + } + if (! isCloud() && isset($payload['is_system_wide'])) { + $rules['is_system_wide'] = 'boolean'; + } + + $validator = customApiValidator($payload, $rules); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation error', + 'errors' => $validator->errors(), + ], 422); + } + + // Handle private_key_uuid -> private_key_id conversion + if (isset($payload['private_key_uuid'])) { + $privateKey = PrivateKey::where('team_id', $teamId) + ->where('uuid', $payload['private_key_uuid']) + ->first(); + + if (! $privateKey) { + return response()->json([ + 'message' => 'Private key not found or does not belong to your team', + ], 404); + } + + unset($payload['private_key_uuid']); + $payload['private_key_id'] = $privateKey->id; + } + + // Update the GitHub app + $githubApp->update($payload); + + return response()->json([ + 'message' => 'GitHub app updated successfully', + 'data' => $githubApp, + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitHub app not found', + ], 404); + } + } + + /** + * Delete a GitHub app. + */ + #[OA\Delete( + path: '/github-apps/{github_app_id}', + operationId: 'deleteGithubApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + summary: 'Delete GitHub App', + description: 'Delete a GitHub app if it\'s not being used by any applications.', + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'GitHub app deleted successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitHub app not found'), + new OA\Response( + response: 409, + description: 'Conflict - GitHub app is in use', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'], + ] + ) + ) + ), + ] + )] + public function delete_github_app($github_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + // Check if the GitHub app is being used by any applications + if ($githubApp->applications->isNotEmpty()) { + $count = $githubApp->applications->count(); + + return response()->json([ + 'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.", + ], 409); + } + + $githubApp->delete(); + + return response()->json([ + 'message' => 'GitHub app deleted successfully', + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitHub app not found', + ], 404); + } + } +} diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 60337a76c..69f71feaf 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -40,6 +40,27 @@ new OA\Property(property: 'message', type: 'string', example: 'Resource not found.'), ] )), + new OA\Response( + response: 422, + description: 'Validation error.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Validation error.'), + new OA\Property( + property: 'errors', + type: 'object', + additionalProperties: new OA\AdditionalProperties( + type: 'array', + items: new OA\Items(type: 'string') + ), + example: [ + 'name' => ['The name field is required.'], + 'api_url' => ['The api url field is required.', 'The api url format is invalid.'], + ] + ), + ] + )), ], )] class OpenApi diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 303e6535d..8f2ba25c8 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -21,8 +21,9 @@ class OtherController extends Controller new OA\Response( response: 200, description: 'Returns the version of the application', - content: new OA\JsonContent( - type: 'string', + content: new OA\MediaType( + mediaType: 'text/html', + schema: new OA\Schema(type: 'string'), example: 'v4.0.0', )), new OA\Response( @@ -166,8 +167,9 @@ public function feedback(Request $request) new OA\Response( response: 200, description: 'Healthcheck endpoint.', - content: new OA\JsonContent( - type: 'string', + content: new OA\MediaType( + mediaType: 'text/html', + schema: new OA\Schema(type: 'string'), example: 'OK', )), new OA\Response( diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 98637c3e8..951a1f5d9 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -4,7 +4,9 @@ use App\Http\Controllers\Controller; use App\Models\Project; +use App\Support\ValidationPatterns; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class ProjectController extends Controller @@ -132,6 +134,10 @@ public function project_by_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function environment_details(Request $request) @@ -212,6 +218,10 @@ public function environment_details(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_project(Request $request) @@ -227,10 +237,10 @@ public function create_project(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255|required', - 'description' => 'string|nullable', - ]); + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ], ValidationPatterns::combinedMessages()); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -322,6 +332,10 @@ public function create_project(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function update_project(Request $request) @@ -337,10 +351,10 @@ public function update_project(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255|nullable', - 'description' => 'string|nullable', - ]); + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(required: false), + 'description' => ValidationPatterns::descriptionRules(), + ], ValidationPatterns::combinedMessages()); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -423,6 +437,10 @@ public function update_project(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function delete_project(Request $request) @@ -447,4 +465,267 @@ public function delete_project(Request $request) return response()->json(['message' => 'Project deleted.']); } + + #[OA\Get( + summary: 'List Environments', + description: 'List all environments in a project.', + path: '/projects/{uuid}/environments', + operationId: 'get-environments', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of environments', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Environment') + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + description: 'Project not found.', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function get_environments(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environments = $project->environments()->select('id', 'name', 'uuid')->get(); + + return response()->json(serializeApiResponse($environments)); + } + + #[OA\Post( + summary: 'Create Environment', + description: 'Create environment in project.', + path: '/projects/{uuid}/environments', + operationId: 'create-environment', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Environment created.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the environment.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'env123', 'description' => 'The UUID of the environment.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + description: 'Project not found.', + ), + new OA\Response( + response: 409, + description: 'Environment with this name already exists.', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_environment(Request $request) + { + $allowedFields = ['name']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(), + ], ValidationPatterns::nameMessages()); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $existingEnvironment = $project->environments()->where('name', $request->name)->first(); + if ($existingEnvironment) { + return response()->json(['message' => 'Environment with this name already exists.'], 409); + } + + $environment = $project->environments()->create([ + 'name' => $request->name, + ]); + + return response()->json([ + 'uuid' => $environment->uuid, + ])->setStatusCode(201); + } + + #[OA\Delete( + summary: 'Delete Environment', + description: 'Delete environment by name or UUID. Environment must be empty.', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}', + operationId: 'delete-environment', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Environment deleted.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + description: 'Environment has resources, so it cannot be deleted.', + ), + new OA\Response( + response: 404, + description: 'Project or environment not found.', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function delete_environment(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + if (! $request->environment_name_or_uuid) { + return response()->json(['message' => 'Environment name or UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); + if (! $environment) { + $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); + } + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + + if (! $environment->isEmpty()) { + return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); + } + + $environment->delete(); + + return response()->json(['message' => 'Environment deleted.']); + } } diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index ad12c83ab..d5dc4a046 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -43,6 +43,10 @@ public function resources(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + + // General authorization check for viewing resources - using Project as base resource type + $this->authorize('viewAny', Project::class); + $projects = Project::where('team_id', $teamId)->get(); $resources = collect(); $resources->push($projects->pluck('applications')->flatten()); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 55a6cd9f4..e7b36cb9a 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -163,6 +163,10 @@ public function key_by_uuid(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_key(Request $request) @@ -282,6 +286,10 @@ public function create_key(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function update_key(Request $request) diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index cbd20400a..06baf2dde 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -447,6 +447,10 @@ public function domains_by_server(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_server(Request $request) @@ -604,6 +608,10 @@ public function create_server(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function update_server(Request $request) @@ -722,6 +730,10 @@ public function update_server(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function delete_server(Request $request) @@ -746,7 +758,13 @@ public function delete_server(Request $request) return response()->json(['message' => 'Local server cannot be deleted.'], 400); } $server->delete(); - DeleteServer::dispatch($server); + DeleteServer::dispatch( + $server->id, + false, // Don't delete from Hetzner via API + $server->hetzner_server_id, + $server->cloud_provider_token_id, + $server->team_id + ); return response()->json(['message' => 'Server deleted.']); } @@ -790,6 +808,10 @@ public function delete_server(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function validate_server(Request $request) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index e792779e1..b3565a933 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -235,6 +235,10 @@ public function services(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_service(Request $request) @@ -246,6 +250,8 @@ public function create_service(Request $request) return invalidTokenResponse(); } + $this->authorize('create', Service::class); + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -322,9 +328,23 @@ public function create_service(Request $request) }); } if ($oneClickService) { - $service_payload = [ + $dockerComposeRaw = base64_decode($oneClickService); + + // Validate for command injection BEFORE creating service + try { + validateDockerComposeForInjection($dockerComposeRaw); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => $e->getMessage(), + ], + ], 422); + } + + $servicePayload = [ 'name' => "$oneClickServiceName-".str()->random(10), - 'docker_compose_raw' => base64_decode($oneClickService), + 'docker_compose_raw' => $dockerComposeRaw, 'environment_id' => $environment->id, 'service_type' => $oneClickServiceName, 'server_id' => $server->id, @@ -332,9 +352,9 @@ public function create_service(Request $request) 'destination_type' => $destination->getMorphClass(), ]; if ($oneClickServiceName === 'cloudflared') { - data_set($service_payload, 'connect_to_docker_network', true); + data_set($servicePayload, 'connect_to_docker_network', true); } - $service = Service::create($service_payload); + $service = Service::create($servicePayload); $service->name = "$oneClickServiceName-".$service->uuid; $service->save(); if ($oneClickDotEnvs?->count() > 0) { @@ -351,7 +371,6 @@ public function create_service(Request $request) 'value' => $generatedValue, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), - 'is_build_time' => false, 'is_preview' => false, ]); }); @@ -377,14 +396,130 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; - $service = new Service; - $result = $this->upsert_service($request, $service, $teamId); - if ($result instanceof \Illuminate\Http\JsonResponse) { - return $result; + $validator = customApiValidator($request->all(), [ + 'project_uuid' => 'string|required', + 'environment_name' => 'string|nullable', + 'environment_uuid' => 'string|nullable', + 'server_uuid' => 'string|required', + 'destination_uuid' => 'string', + 'name' => 'string|max:255', + 'description' => 'string|nullable', + 'instant_deploy' => 'boolean', + 'connect_to_docker_network' => 'boolean', + 'docker_compose_raw' => 'string|required', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); } - return response()->json(serializeApiResponse($result))->setStatusCode(201); + $environmentUuid = $request->environment_uuid; + $environmentName = $request->environment_name; + if (blank($environmentUuid) && blank($environmentName)) { + return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422); + } + $serverUuid = $request->server_uuid; + $projectUuid = $request->project_uuid; + $project = Project::whereTeamId($teamId)->whereUuid($projectUuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + $environment = $project->environments()->where('name', $environmentName)->first(); + if (! $environment) { + $environment = $project->environments()->where('uuid', $environmentUuid)->first(); + } + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $destinations = $server->destinations(); + if ($destinations->count() == 0) { + return response()->json(['message' => 'Server has no destinations.'], 400); + } + if ($destinations->count() > 1 && ! $request->has('destination_uuid')) { + return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); + } + $destination = $destinations->first(); + if (! isBase64Encoded($request->docker_compose_raw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerComposeRaw = base64_decode($request->docker_compose_raw); + if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerCompose = base64_decode($request->docker_compose_raw); + $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + + // Validate for command injection BEFORE saving to database + try { + validateDockerComposeForInjection($dockerComposeRaw); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => $e->getMessage(), + ], + ], 422); + } + + $connectToDockerNetwork = $request->connect_to_docker_network ?? false; + $instantDeploy = $request->instant_deploy ?? false; + + $service = new Service; + $service->name = $request->name ?? 'service-'.str()->random(10); + $service->description = $request->description; + $service->docker_compose_raw = $dockerComposeRaw; + $service->environment_id = $environment->id; + $service->server_id = $server->id; + $service->destination_id = $destination->id; + $service->destination_type = $destination->getMorphClass(); + $service->connect_to_docker_network = $connectToDockerNetwork; + $service->save(); + + $service->parse(isNew: true); + if ($instantDeploy) { + StartService::dispatch($service); + } + + $domains = $service->applications()->get()->pluck('fqdn')->sort(); + $domains = $domains->map(function ($domain) { + if (count(explode(':', $domain)) > 2) { + return str($domain)->beforeLast(':')->value(); + } + + return $domain; + })->values(); + + return response()->json([ + 'uuid' => $service->uuid, + 'domains' => $domains, + ])->setStatusCode(201); } else { return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400); } @@ -443,6 +578,8 @@ public function service_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('view', $service); + $service = $service->load(['applications', 'databases']); return response()->json($this->removeSensitiveData($service)); @@ -508,12 +645,14 @@ public function delete_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('delete', $service); + DeleteResourceJob::dispatch( resource: $service, - deleteConfigurations: $request->query->get('delete_configurations', true), deleteVolumes: $request->query->get('delete_volumes', true), - dockerCleanup: $request->query->get('docker_cleanup', true), - deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true), + deleteConfigurations: $request->query->get('delete_configurations', true), + dockerCleanup: $request->query->get('docker_cleanup', true) ); return response()->json([ @@ -550,7 +689,6 @@ public function delete_by_uuid(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'], properties: [ 'name' => ['type' => 'string', 'description' => 'The service name.'], 'description' => ['type' => 'string', 'description' => 'The service description.'], @@ -596,6 +734,10 @@ public function delete_by_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function update_by_uuid(Request $request) @@ -615,28 +757,16 @@ public function update_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } - $result = $this->upsert_service($request, $service, $teamId); - if ($result instanceof \Illuminate\Http\JsonResponse) { - return $result; - } + $this->authorize('update', $service); - return response()->json(serializeApiResponse($result))->setStatusCode(200); - } + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; - private function upsert_service(Request $request, Service $service, string $teamId) - { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; $validator = customApiValidator($request->all(), [ - 'project_uuid' => 'string|required', - 'environment_name' => 'string|nullable', - 'environment_uuid' => 'string|nullable', - 'server_uuid' => 'string|required', - 'destination_uuid' => 'string', 'name' => 'string|max:255', 'description' => 'string|nullable', 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', - 'docker_compose_raw' => 'string|required', + 'docker_compose_raw' => 'string|nullable', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -653,70 +783,55 @@ private function upsert_service(Request $request, Service $service, string $team 'errors' => $errors, ], 422); } + if ($request->has('docker_compose_raw')) { + if (! isBase64Encoded($request->docker_compose_raw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerComposeRaw = base64_decode($request->docker_compose_raw); + if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerCompose = base64_decode($request->docker_compose_raw); + $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); - $environmentUuid = $request->environment_uuid; - $environmentName = $request->environment_name; - if (blank($environmentUuid) && blank($environmentName)) { - return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422); - } - $serverUuid = $request->server_uuid; - $instantDeploy = $request->instant_deploy ?? false; - $project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first(); - if (! $project) { - return response()->json(['message' => 'Project not found.'], 404); - } - $environment = $project->environments()->where('name', $environmentName)->first(); - if (! $environment) { - $environment = $project->environments()->where('uuid', $environmentUuid)->first(); - } - if (! $environment) { - return response()->json(['message' => 'Environment not found.'], 404); - } - $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); - if (! $server) { - return response()->json(['message' => 'Server not found.'], 404); - } - $destinations = $server->destinations(); - if ($destinations->count() == 0) { - return response()->json(['message' => 'Server has no destinations.'], 400); - } - if ($destinations->count() > 1 && ! $request->has('destination_uuid')) { - return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); - } - $destination = $destinations->first(); - if (! isBase64Encoded($request->docker_compose_raw)) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', - ], - ], 422); - } - $dockerComposeRaw = base64_decode($request->docker_compose_raw); - if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', - ], - ], 422); - } - $dockerCompose = base64_decode($request->docker_compose_raw); - $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); - $connectToDockerNetwork = $request->connect_to_docker_network ?? false; + // Validate for command injection BEFORE saving to database + try { + validateDockerComposeForInjection($dockerComposeRaw); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => $e->getMessage(), + ], + ], 422); + } - $service->name = $request->name ?? null; - $service->description = $request->description ?? null; - $service->docker_compose_raw = $dockerComposeRaw; - $service->environment_id = $environment->id; - $service->server_id = $server->id; - $service->destination_id = $destination->id; - $service->destination_type = $destination->getMorphClass(); - $service->connect_to_docker_network = $connectToDockerNetwork; + $service->docker_compose_raw = $dockerComposeRaw; + } + + if ($request->has('name')) { + $service->name = $request->name; + } + if ($request->has('description')) { + $service->description = $request->description; + } + if ($request->has('connect_to_docker_network')) { + $service->connect_to_docker_network = $request->connect_to_docker_network; + } $service->save(); $service->parse(); - if ($instantDeploy) { + if ($request->instant_deploy) { StartService::dispatch($service); } @@ -729,10 +844,10 @@ private function upsert_service(Request $request, Service $service, string $team return $domain; })->values(); - return [ + return response()->json([ 'uuid' => $service->uuid, 'domains' => $domains, - ]; + ])->setStatusCode(200); } #[OA\Get( @@ -795,6 +910,8 @@ public function envs(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $envs = $service->environment_variables->map(function ($env) { $env->makeHidden([ 'application_id', @@ -848,7 +965,6 @@ public function envs(Request $request) 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -885,6 +1001,10 @@ public function envs(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function update_env_by_uuid(Request $request) @@ -899,10 +1019,11 @@ public function update_env_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -966,7 +1087,6 @@ public function update_env_by_uuid(Request $request) 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -1006,6 +1126,10 @@ public function update_env_by_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_bulk_envs(Request $request) @@ -1020,6 +1144,8 @@ public function create_bulk_envs(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $bulk_data = $request->get('data'); if (! $bulk_data) { return response()->json(['message' => 'Bulk data is required.'], 400); @@ -1030,7 +1156,6 @@ public function create_bulk_envs(Request $request) $validator = customApiValidator($item, [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -1086,7 +1211,6 @@ public function create_bulk_envs(Request $request) 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -1122,6 +1246,10 @@ public function create_bulk_envs(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), ] )] public function create_env(Request $request) @@ -1136,10 +1264,11 @@ public function create_env(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -1238,6 +1367,8 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $env = EnvironmentVariable::where('uuid', $request->env_uuid) ->where('resourceable_type', Service::class) ->where('resourceable_id', $service->id) @@ -1317,6 +1448,9 @@ public function action_deploy(Request $request) if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } + + $this->authorize('deploy', $service); + if (str($service->status)->contains('running')) { return response()->json(['message' => 'Service is already running.'], 400); } @@ -1395,6 +1529,9 @@ public function action_stop(Request $request) if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } + + $this->authorize('stop', $service); + if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) { return response()->json(['message' => 'Service is already stopped.'], 400); } @@ -1428,6 +1565,15 @@ public function action_stop(Request $request) format: 'uuid', ) ), + new OA\Parameter( + name: 'latest', + in: 'query', + description: 'Pull latest images.', + schema: new OA\Schema( + type: 'boolean', + default: false, + ) + ), ], responses: [ new OA\Response( @@ -1473,7 +1619,11 @@ public function action_restart(Request $request) if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - RestartService::dispatch($service); + + $this->authorize('deploy', $service); + + $pullLatest = $request->boolean('latest'); + RestartService::dispatch($service, $pullLatest); return response()->json( [ diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index d4b24d8ab..e12d83542 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -179,6 +179,8 @@ public function members_by_id(Request $request) $members = $team->members; $members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( @@ -264,6 +266,8 @@ public function current_team_members(Request $request) $team = auth()->user()->currentTeam(); $team->members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 14a2950af..09007ad96 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -144,7 +144,7 @@ public function acceptInvitation() } } - public function revoke_invitation() + public function revokeInvitation() { $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail(); $user = User::whereEmail($invitation->email)->firstOrFail(); diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 490b66e58..078494f82 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -143,12 +143,13 @@ public function manual(Request $request) ]); $pr_app->generate_preview_fqdn_compose(); } else { - ApplicationPreview::create([ + $pr_app = ApplicationPreview::create([ 'git_type' => 'bitbucket', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, 'pull_request_html_url' => $pull_request_html_url, ]); + $pr_app->generate_preview_fqdn(); } } $result = queue_application_deployment( diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 3c3d6e0b6..3e0c5a0b6 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -175,12 +175,13 @@ public function manual(Request $request) ]); $pr_app->generate_preview_fqdn_compose(); } else { - ApplicationPreview::create([ + $pr_app = ApplicationPreview::create([ 'git_type' => 'gitea', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, 'pull_request_html_url' => $pull_request_html_url, ]); + $pr_app->generate_preview_fqdn(); } } $result = queue_application_deployment( diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 597ec023f..5ba9c08e7 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -5,6 +5,7 @@ use App\Enums\ProcessStatus; use App\Http\Controllers\Controller; use App\Jobs\ApplicationPullRequestUpdateJob; +use App\Jobs\DeleteResourceJob; use App\Jobs\GithubAppPermissionJob; use App\Models\Application; use App\Models\ApplicationPreview; @@ -78,6 +79,7 @@ public function manual(Request $request) $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); + $author_association = data_get($payload, 'pull_request.author_association'); } if (! $branch) { return response('Nothing to do. No branch found in the request.'); @@ -95,150 +97,168 @@ public function manual(Request $request) return response("Nothing to do. No applications found with branch '$base_branch'."); } } - foreach ($applications as $application) { - $webhook_secret = data_get($application, 'manual_webhook_secret_github'); - $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); - if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $applicationsByServer = $applications->groupBy(function ($app) { + return $app->destination->server_id; + }); - continue; - } - $isFunctional = $application->destination->server->isFunctional(); - if (! $isFunctional) { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Server is not functional.', - ]); - - continue; - } - if ($x_github_event === 'push') { - if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || is_null($application->watch_paths)) { - $deployment_uuid = new Cuid2; - $result = queue_application_deployment( - application: $application, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'after', 'HEAD'), - is_webhook: true, - ); - if ($result['status'] === 'skipped') { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Deployment queued.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], - ]); - } - } else { - $paths = str($application->watch_paths)->explode("\n"); - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Changed files do not match watch paths. Ignoring deployment.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'details' => [ - 'changed_files' => $changed_files, - 'watch_paths' => $paths, - ], - ]); - } - } else { + foreach ($applicationsByServer as $serverId => $serverApplications) { + foreach ($serverApplications as $application) { + $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); + if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ + 'application' => $application->name, 'status' => 'failed', - 'message' => 'Deployments disabled.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, + 'message' => 'Invalid signature.', ]); + + continue; } - } - if ($x_github_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { - if ($application->isPRDeployable()) { - $deployment_uuid = new Cuid2; - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found) { - if ($application->build_pack === 'dockercompose') { - $pr_app = ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, - 'docker_compose_domains' => $application->docker_compose_domains, + $isFunctional = $application->destination->server->isFunctional(); + if (! $isFunctional) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional.', + ]); + + continue; + } + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if ($is_watch_path_triggered || is_null($application->watch_paths)) { + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'after', 'HEAD'), + is_webhook: true, + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], ]); - $pr_app->generate_preview_fqdn_compose(); } else { - ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Deployment queued.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], ]); } + } else { + $paths = str($application->watch_paths)->explode("\n"); + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths. Ignoring deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'details' => [ + 'changed_files' => $changed_files, + 'watch_paths' => $paths, + ], + ]); } + } else { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Deployments disabled.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + } + } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + // Check if PR deployments from public contributors are restricted + if (! $application->settings->is_pr_deployments_public_enabled) { + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + if (! in_array($author_association, $trustedAssociations)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association, + ]); - $result = queue_application_deployment( - application: $application, - pull_request_id: $pull_request_id, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), - is_webhook: true, - git_type: 'github' - ); - if ($result['status'] === 'skipped') { + continue; + } + } + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (! $found) { + if ($application->build_pack === 'dockercompose') { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + $pr_app->generate_preview_fqdn_compose(); + } else { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + $pr_app->generate_preview_fqdn(); + } + } + + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'head.sha', 'HEAD'), + is_webhook: true, + git_type: 'github' + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment queued.', + ]); + } + } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], + 'status' => 'failed', + 'message' => 'Preview deployments disabled.', + ]); + } + } + if ($action === 'closed') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + DeleteResourceJob::dispatch($found); + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', ]); } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment queued.', + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); } - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Preview deployments disabled.', - ]); - } - } - if ($action === 'closed') { - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment closed.', - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No preview deployment found.', - ]); } } } @@ -326,6 +346,7 @@ public function normal(Request $request) $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); + $author_association = data_get($payload, 'pull_request.author_association'); } if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); @@ -343,127 +364,147 @@ public function normal(Request $request) return response("Nothing to do. No applications found with branch '$base_branch'."); } } - foreach ($applications as $application) { - $isFunctional = $application->destination->server->isFunctional(); - if (! $isFunctional) { - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Server is not functional.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - ]); + $applicationsByServer = $applications->groupBy(function ($app) { + return $app->destination->server_id; + }); - continue; - } - if ($x_github_event === 'push') { - if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || is_null($application->watch_paths)) { - $deployment_uuid = new Cuid2; - $result = queue_application_deployment( - application: $application, - deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'after', 'HEAD'), - force_rebuild: false, - is_webhook: true, - ); - $return_payloads->push([ - 'status' => $result['status'], - 'message' => $result['message'], - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], - ]); - } else { - $paths = str($application->watch_paths)->explode("\n"); - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Changed files do not match watch paths. Ignoring deployment.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'details' => [ - 'changed_files' => $changed_files, - 'watch_paths' => $paths, - ], - ]); - } - } else { + foreach ($applicationsByServer as $serverId => $serverApplications) { + foreach ($serverApplications as $application) { + $isFunctional = $application->destination->server->isFunctional(); + if (! $isFunctional) { $return_payloads->push([ 'status' => 'failed', - 'message' => 'Deployments disabled.', + 'message' => 'Server is not functional.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, ]); + + continue; } - } - if ($x_github_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { - if ($application->isPRDeployable()) { - $deployment_uuid = new Cuid2; - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found) { - ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if ($is_watch_path_triggered || is_null($application->watch_paths)) { + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'after', 'HEAD'), + force_rebuild: false, + is_webhook: true, + ); + $return_payloads->push([ + 'status' => $result['status'], + 'message' => $result['message'], + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + ]); + } else { + $paths = str($application->watch_paths)->explode("\n"); + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths. Ignoring deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'details' => [ + 'changed_files' => $changed_files, + 'watch_paths' => $paths, + ], ]); } - $result = queue_application_deployment( - application: $application, - pull_request_id: $pull_request_id, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), - is_webhook: true, - git_type: 'github' - ); - if ($result['status'] === 'skipped') { + } else { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Deployments disabled.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + } + } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + // Check if PR deployments from public contributors are restricted + if (! $application->settings->is_pr_deployments_public_enabled) { + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + if (! in_array($author_association, $trustedAssociations)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association, + ]); + + continue; + } + } + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (! $found) { + ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + } + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'head.sha', 'HEAD'), + is_webhook: true, + git_type: 'github' + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment queued.', + ]); + } + } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], + 'status' => 'failed', + 'message' => 'Preview deployments disabled.', + ]); + } + } + if ($action === 'closed' || $action === 'close') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); + if ($containers->isNotEmpty()) { + $containers->each(function ($container) use ($application) { + $container_name = data_get($container, 'Names'); + instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + }); + } + + ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); + + DeleteResourceJob::dispatch($found); + + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', ]); } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment queued.', + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); } - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Preview deployments disabled.', - ]); - } - } - if ($action === 'closed' || $action === 'close') { - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if ($found) { - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); - if ($containers->isNotEmpty()) { - $containers->each(function ($container) use ($application) { - $container_name = data_get($container, 'Names'); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - }); - } - - ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); - $found->delete(); - - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment closed.', - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No preview deployment found.', - ]); } } } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index d6d12a05f..3187663d4 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -202,12 +202,13 @@ public function manual(Request $request) ]); $pr_app->generate_preview_fqdn_compose(); } else { - ApplicationPreview::create([ + $pr_app = ApplicationPreview::create([ 'git_type' => 'gitlab', 'application_id' => $application->id, 'pull_request_id' => $pull_request_id, 'pull_request_html_url' => $pull_request_html_url, ]); + $pr_app->generate_preview_fqdn(); } } $result = queue_application_deployment( diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index 83ba16699..ae50aac42 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -4,15 +4,12 @@ use App\Http\Controllers\Controller; use App\Jobs\StripeProcessJob; -use App\Models\Webhook; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class Stripe extends Controller { - protected $webhook; - public function events(Request $request) { try { @@ -40,19 +37,10 @@ public function events(Request $request) return response('Webhook received. Cool cool cool cool cool.', 200); } - $this->webhook = Webhook::create([ - 'type' => 'stripe', - 'payload' => $request->getContent(), - ]); StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); } catch (Exception $e) { - $this->webhook->update([ - 'status' => 'failed', - 'failure_reason' => $e->getMessage(), - ]); - return response($e->getMessage(), 400); } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a1ce20295..515d40c62 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -14,7 +14,7 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, + \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, @@ -71,5 +71,8 @@ class Kernel extends HttpKernel 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'api.ability' => \App\Http\Middleware\ApiAbility::class, 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class, + 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class, + 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class, + 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class, ]; } diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index dc6be5da3..21441a117 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -18,12 +18,18 @@ public function handle(Request $request, Closure $next): Response return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); } - if (! isDev()) { - if ($settings->allowed_ips) { - $allowedIps = explode(',', $settings->allowed_ips); - if (! in_array($request->ip(), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); - } + if ($settings->allowed_ips) { + // Check for special case: 0.0.0.0 means allow all + if (trim($settings->allowed_ips) === '0.0.0.0') { + return $next($request); + } + + $allowedIps = explode(',', $settings->allowed_ips); + $allowedIps = array_map('trim', $allowedIps); + $allowedIps = array_filter($allowedIps); // Remove empty entries + + if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { + return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); } } diff --git a/app/Http/Middleware/CanAccessTerminal.php b/app/Http/Middleware/CanAccessTerminal.php new file mode 100644 index 000000000..348f389ea --- /dev/null +++ b/app/Http/Middleware/CanAccessTerminal.php @@ -0,0 +1,29 @@ +check()) { + abort(401, 'Authentication required'); + } + + // Only admins/owners can access terminal functionality + if (! auth()->user()->can('canAccessTerminal')) { + abort(403, 'Access to terminal functionality is restricted to team administrators'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CanCreateResources.php b/app/Http/Middleware/CanCreateResources.php new file mode 100644 index 000000000..ba0ab67c1 --- /dev/null +++ b/app/Http/Middleware/CanCreateResources.php @@ -0,0 +1,26 @@ +route('application_uuid')) { + // $resource = Application::where('uuid', $request->route('application_uuid'))->first(); + // } elseif ($request->route('service_uuid')) { + // $resource = Service::where('uuid', $request->route('service_uuid'))->first(); + // } elseif ($request->route('stack_service_uuid')) { + // // Handle ServiceApplication or ServiceDatabase + // $stack_service_uuid = $request->route('stack_service_uuid'); + // $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ?? + // ServiceDatabase::where('uuid', $stack_service_uuid)->first(); + // } elseif ($request->route('database_uuid')) { + // // Try different database types + // $database_uuid = $request->route('database_uuid'); + // $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ?? + // StandaloneMysql::where('uuid', $database_uuid)->first() ?? + // StandaloneMariadb::where('uuid', $database_uuid)->first() ?? + // StandaloneRedis::where('uuid', $database_uuid)->first() ?? + // StandaloneKeydb::where('uuid', $database_uuid)->first() ?? + // StandaloneDragonfly::where('uuid', $database_uuid)->first() ?? + // StandaloneClickhouse::where('uuid', $database_uuid)->first() ?? + // StandaloneMongodb::where('uuid', $database_uuid)->first(); + // } elseif ($request->route('server_uuid')) { + // // For server routes, check if user can manage servers + // if (! auth()->user()->isAdmin()) { + // abort(403, 'You do not have permission to access this resource.'); + // } + + // return $next($request); + // } elseif ($request->route('environment_uuid')) { + // $resource = Environment::where('uuid', $request->route('environment_uuid'))->first(); + // } elseif ($request->route('project_uuid')) { + // $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first(); + // } + + // if (! $resource) { + // abort(404, 'Resource not found.'); + // } + + // if (! Gate::allows('update', $resource)) { + // abort(403, 'You do not have permission to update this resource.'); + // } + + // return $next($request); + } +} diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index c9c58bddc..c2a2cb41a 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -2,7 +2,10 @@ namespace App\Http\Middleware; +use App\Models\InstanceSettings; use Illuminate\Http\Middleware\TrustHosts as Middleware; +use Illuminate\Support\Facades\Cache; +use Spatie\Url\Url; class TrustHosts extends Middleware { @@ -13,8 +16,37 @@ class TrustHosts extends Middleware */ public function hosts(): array { - return [ - $this->allSubdomainsOfApplicationUrl(), - ]; + $trustedHosts = []; + + // Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request) + // Use empty string as sentinel value instead of null so negative results are cached + $fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () { + try { + $settings = InstanceSettings::get(); + if ($settings && $settings->fqdn) { + $url = Url::fromString($settings->fqdn); + $host = $url->getHost(); + + return $host ?: ''; + } + } catch (\Exception $e) { + // If instance settings table doesn't exist yet (during installation), + // return empty string (sentinel) so this result is cached + } + + return ''; + }); + + // Convert sentinel value back to null for consumption + $fqdnHost = $fqdnHost !== '' ? $fqdnHost : null; + + if ($fqdnHost) { + $trustedHosts[] = $fqdnHost; + } + + // Trust all subdomains of APP_URL as fallback + $trustedHosts[] = $this->allSubdomainsOfApplicationUrl(); + + return array_filter($trustedHosts); } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 9e8652172..f07050d5e 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + 'webhooks/*', ]; } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ad203fb1c..a624348c0 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -5,6 +5,7 @@ use App\Actions\Docker\GetContainersStatus; use App\Enums\ApplicationDeploymentStatus; use App\Enums\ProcessStatus; +use App\Events\ApplicationConfigurationChanged; use App\Events\ServiceStatusChanged; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; @@ -17,6 +18,7 @@ use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; use Carbon\Carbon; use Exception; @@ -30,14 +32,14 @@ use Illuminate\Support\Sleep; use Illuminate\Support\Str; use RuntimeException; +use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; use Visus\Cuid2\Cuid2; -use Yosymfony\Toml\Toml; class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels; public $tries = 1; @@ -114,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; - private $environment_variables; - private $env_nixpacks_args; private $docker_compose; private $docker_compose_base64; - private ?string $env_filename = null; - private ?string $nixpacks_plan = null; private Collection $nixpacks_plan_json; @@ -146,6 +144,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection $saved_outputs; + private ?string $secrets_hash_key = null; + private ?string $full_healthcheck_url = null; private string $serverUser = 'root'; @@ -166,6 +166,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $preserveRepository = false; + private bool $dockerBuildkitSupported = false; + + private bool $skip_build = false; + + private Collection|string $build_secrets; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -182,6 +188,7 @@ public function __construct(public int $application_deployment_queue_id) $this->application = Application::find($this->application_deployment_queue->application_id); $this->build_pack = data_get($this->application, 'build_pack'); $this->build_args = collect([]); + $this->build_secrets = ''; $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; @@ -220,7 +227,7 @@ public function __construct(public int $application_deployment_queue_id) if ($this->pull_request_id === 0) { $this->container_name = $this->application->settings->custom_internal_name; } else { - $this->container_name = "{$this->application->settings->custom_internal_name}-pr-{$this->pull_request_id}"; + $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); } } @@ -228,7 +235,14 @@ public function __construct(public int $application_deployment_queue_id) // Set preview fqdn if ($this->pull_request_id !== 0) { - $this->preview = $this->application->generate_preview_fqdn($this->pull_request_id); + $this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id); + if ($this->preview) { + if ($this->application->build_pack === 'dockercompose') { + $this->preview->generate_preview_fqdn_compose(); + } else { + $this->preview->generate_preview_fqdn(); + } + } if ($this->application->is_github_based()) { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS); } @@ -242,6 +256,14 @@ public function __construct(public int $application_deployment_queue_id) public function handle(): void { + // Check if deployment was cancelled before we even started + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.'); + + return; + } + $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'horizon_job_worker' => gethostname(), @@ -255,7 +277,6 @@ public function handle(): void try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); - // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -311,6 +332,7 @@ public function handle(): void $this->build_server = $this->server; $this->original_server = $this->server; } + $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { if ($this->pull_request_id !== 0 && $this->application->is_github_based()) { @@ -328,6 +350,7 @@ public function handle(): void } else { $this->write_deployment_configurations(); } + $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid); @@ -335,6 +358,80 @@ public function handle(): void } } + private function detectBuildKitCapabilities(): void + { + // If build secrets are not enabled, skip detection and use traditional args + if (! $this->application->settings->use_build_secrets) { + $this->dockerBuildkitSupported = false; + + return; + } + + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; + $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; + + try { + $dockerVersion = instant_remote_process( + ["docker version --format '{{.Server.Version}}'"], + $serverToCheck + ); + + $versionParts = explode('.', $dockerVersion); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) ($versionParts[1] ?? 0); + + if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled."); + + return; + } + + $buildkitEnabled = instant_remote_process( + ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], + $serverToCheck + ); + + if (trim($buildkitEnabled) !== 'available') { + $buildkitTest = instant_remote_process( + ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($buildkitTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + } + } else { + // Buildx is available, which means BuildKit is available + // Now specifically test for secrets support + $secretsTest = instant_remote_process( + ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($secretsTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + } + } + } catch (\Exception $e) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.'); + } + } + private function decide_what_to_do() { if ($this->restart_only) { @@ -361,9 +458,7 @@ private function decide_what_to_do() private function post_deployment() { - if ($this->server->isProxyShouldRun()) { - GetContainersStatus::dispatch($this->server); - } + GetContainersStatus::dispatch($this->server); $this->next(ApplicationDeploymentStatus::FINISHED->value); if ($this->pull_request_id !== 0) { if ($this->application->is_github_based()) { @@ -389,9 +484,18 @@ private function deploy_simple_dockerfile() ); $this->generate_image_names(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -404,7 +508,12 @@ private function deploy_dockerimage_buildpack() } else { $this->dockerImageTag = $this->application->docker_registry_image_tag; } - $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}."); + + // Check if this is an image hash deployment + $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); + $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}"; + + $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}."); $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); @@ -465,29 +574,41 @@ private function deploy_docker_compose_buildpack() } $this->generate_image_names(); $this->cleanup_git(); + + $this->generate_build_env_variables(); + $this->application->loadComposeFile(isInit: false); if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->save_environment_variables(); + + // For raw compose, we cannot automatically add secrets configuration + // User must define it manually in their docker-compose file + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); + } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->save_environment_variables(); - if (! is_null($this->env_filename)) { - $services = collect(data_get($composeFile, 'services', [])); - $services = $services->map(function ($service, $name) { - $service['env_file'] = [$this->env_filename]; + // Always add .env file to services + $services = collect(data_get($composeFile, 'services', [])); + $services = $services->map(function ($service, $name) { + $service['env_file'] = ['.env']; - return $service; - }); - $composeFile['services'] = $services->toArray(); - } - if (is_null($composeFile)) { + return $service; + }); + $composeFile['services'] = $services->toArray(); + if (empty($composeFile)) { $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.'); return; } + + // Add build secrets to compose file if enabled and BuildKit is supported + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $composeFile = $this->add_build_secrets_to_compose($composeFile); + } + $yaml = Yaml::dump(convertToArray($composeFile), 10); } $this->docker_compose_base64 = base64_encode($yaml); @@ -495,28 +616,55 @@ private function deploy_docker_compose_buildpack() executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, ]); + + // Modify Dockerfiles for ARGs and build secrets + $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + if ($this->docker_compose_custom_build_command) { + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + $build_command = $this->docker_compose_custom_build_command; + if ($this->dockerBuildkitSupported) { + $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; + } $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), 'hidden' => true], + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], ); } else { $command = "{$this->coolify_variables} docker compose"; - if ($this->env_filename) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + if ($this->dockerBuildkitSupported) { + $command = "DOCKER_BUILDKIT=1 {$command}"; } + // Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image) + $command .= ' --env-file /artifacts/build-time.env'; if ($this->force_rebuild) { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache"; } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } + + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { + $build_args_string = $this->build_args->implode(' '); + // Escape single quotes for bash -c context used by executeInDocker + $build_args_string = str_replace("'", "'\\''", $build_args_string); + $command .= " {$build_args_string}"; + $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); + } + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); } + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -550,9 +698,8 @@ private function deploy_docker_compose_buildpack() $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; - if ($this->env_filename) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( ['command' => $command, 'hidden' => true], @@ -567,9 +714,8 @@ private function deploy_docker_compose_buildpack() } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { - if ($this->env_filename) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->write_deployment_configurations(); @@ -577,9 +723,8 @@ private function deploy_docker_compose_buildpack() ['command' => $command, 'hidden' => true], ); } else { - if ($this->env_filename) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], @@ -613,9 +758,18 @@ private function deploy_dockerfile_buildpack() } $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -639,8 +793,16 @@ private function deploy_nixpacks_buildpack() $this->cleanup_git(); $this->generate_nixpacks_confs(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build for Nixpacks + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->build_image(); + + // For Nixpacks, save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -663,7 +825,16 @@ private function deploy_static_buildpack() $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); - $this->build_image(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + + $this->build_static_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -706,8 +877,8 @@ private function write_deployment_configurations() if ($this->pull_request_id === 0) { $composeFileName = "$mainDir/docker-compose.yaml"; } else { - $composeFileName = "$mainDir/docker-compose-pr-{$this->pull_request_id}.yaml"; - $this->docker_compose_location = "/docker-compose-pr-{$this->pull_request_id}.yaml"; + $composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; + $this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; } $this->execute_remote_command( [ @@ -795,7 +966,13 @@ private function generate_image_names() $this->production_image_name = "{$this->application->uuid}:latest"; } } elseif ($this->application->build_pack === 'dockerimage') { - $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + // Check if this is an image hash deployment + if (str($this->dockerImageTag)->startsWith('sha256-')) { + $hash = str($this->dockerImageTag)->after('sha256-'); + $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}"; + } else { + $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + } } elseif ($this->pull_request_id !== 0) { if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; @@ -834,19 +1011,26 @@ private function should_skip_build() { if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) { if ($this->is_this_additional_server) { + $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); + + // Save runtime environment variables even when skipping build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); - if ($this->restart_only) { - $this->post_deployment(); - } return true; } if (! $this->application->isConfigurationChanged()) { $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); + $this->skip_build = true; $this->generate_compose_file(); + + // Save runtime environment variables even when skipping build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); @@ -886,13 +1070,9 @@ private function check_image_locally_or_remotely() } } - private function save_environment_variables() + private function generate_runtime_environment_variables() { $envs = collect([]); - $local_branch = $this->branch; - if ($this->pull_request_id !== 0) { - $local_branch = "pull/{$this->pull_request_id}/head"; - } $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { $sorted_environment_variables = $this->application->environment_variables->sortBy('key'); @@ -901,26 +1081,66 @@ private function save_environment_variables() $sorted_environment_variables = $this->application->environment_variables->sortBy('id'); $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); } + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); + }); + $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); + }); + } $ports = $this->application->main_port(); $coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs->each(function ($item, $key) use ($envs) { $envs->push($key.'='.$item); }); if ($this->pull_request_id === 0) { - $this->env_filename = '.env'; + // Generate SERVICE_ variables first for dockercompose + if ($this->build_pack === 'dockercompose') { + $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); - foreach ($sorted_environment_variables as $env) { - $real_value = $env->real_value; - if ($env->version === '4.0.0-beta.239') { - $real_value = $env->real_value; - } else { - if ($env->is_literal || $env->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($env->real_value); + // Generate SERVICE_FQDN & SERVICE_URL for dockercompose + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); } } - $envs->push($env->key.'='.$real_value); + + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + } + } + + // Filter runtime variables (only include variables that are available at runtime) + $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) { + return $env->is_runtime; + }); + + // Sort runtime environment variables: those referencing SERVICE_ variables come after others + $runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) { + if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { + return 2; + } + + return 1; + }); + + foreach ($runtime_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { @@ -933,19 +1153,48 @@ private function save_environment_variables() $envs->push('HOST=0.0.0.0'); } } else { - $this->env_filename = ".env-pr-$this->pull_request_id"; - foreach ($sorted_environment_variables_preview as $env) { - $real_value = $env->real_value; - if ($env->version === '4.0.0-beta.239') { - $real_value = $env->real_value; - } else { - if ($env->is_literal || $env->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($env->real_value); + // Generate SERVICE_ variables first for dockercompose preview + if ($this->build_pack === 'dockercompose') { + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + + // Generate SERVICE_FQDN & SERVICE_URL for dockercompose + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); } } - $envs->push($env->key.'='.$real_value); + + // Generate SERVICE_NAME for dockercompose services + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); + } + } + + // Filter runtime variables for preview (only include variables that are available at runtime) + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { + return $env->is_runtime; + }); + + // Sort runtime environment variables: those referencing SERVICE_ variables come after others + $runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) { + if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { + return 2; + } + + return 1; + }); + + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { @@ -957,61 +1206,314 @@ private function save_environment_variables() if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } - } - if ($envs->isEmpty()) { - $this->env_filename = null; - if ($this->use_build_server) { - $this->server = $this->original_server; + + // Return the generated environment variables instead of storing them globally + return $envs; + } + + private function save_runtime_environment_variables() + { + // This method saves the .env file with ALL runtime variables + // For builds, it should be called AFTER the build to include runtime-only variables + + // Generate runtime environment variables locally + $environment_variables = $this->generate_runtime_environment_variables(); + + // Handle empty environment variables + if ($environment_variables->isEmpty()) { + // For Docker Compose, we need to create an empty .env file + // because we always reference it in the compose file + if ($this->build_pack === 'dockercompose') { + $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); + + // Create empty .env file $this->execute_remote_command( [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - $this->server = $this->build_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, + executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"), ] ); + + // Also create in configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + } } else { - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); + // For non-Docker Compose deployments, clean up any existing .env files + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + $this->server = $this->build_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } else { + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } } - } else { - $envs_base64 = base64_encode($envs->implode("\n")); + + return; + } + + // Write the environment variables to file + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) + $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"), + 'hidden' => true, + + ] + ); + + // Write .env file to configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), - ], - + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] ); - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + } + } + + private function generate_buildtime_environment_variables() + { + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + } + + $envs = collect([]); + $coolify_envs = $this->generate_coolify_env_variables(); + + // Add COOLIFY variables + $coolify_envs->each(function ($item, $key) use ($envs) { + $envs->push($key.'='.escapeBashEnvValue($item)); + }); + + // Add SERVICE_NAME variables for Docker Compose builds + if ($this->build_pack === 'dockercompose') { + if ($this->pull_request_id === 0) { + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName)); + } + + // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments + $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); + } + } } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); + // Generate SERVICE_NAME for preview deployments + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id))); + } + + // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); + } + } } } - $this->environment_variables = $envs; + + // Add build-time user variables only + if ($this->pull_request_id === 0) { + $sorted_environment_variables = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + // For literal/multiline vars, real_value includes quotes that we need to remove + if ($env->is_literal || $env->is_multiline) { + // Strip outer quotes from real_value and apply proper bash escaping + $value = trim($env->real_value, "'"); + $escapedValue = escapeBashEnvValue($value); + $envs->push($env->key.'='.$escapedValue); + + if (isDev()) { + $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); + $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); + } + } else { + // For normal vars, use double quotes to allow $VAR expansion + $escapedValue = escapeBashDoubleQuoted($env->real_value); + $envs->push($env->key.'='.$escapedValue); + + if (isDev()) { + $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); + $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); + } + } + } + } else { + $sorted_environment_variables = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + // For literal/multiline vars, real_value includes quotes that we need to remove + if ($env->is_literal || $env->is_multiline) { + // Strip outer quotes from real_value and apply proper bash escaping + $value = trim($env->real_value, "'"); + $escapedValue = escapeBashEnvValue($value); + $envs->push($env->key.'='.$escapedValue); + + if (isDev()) { + $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); + $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); + } + } else { + // For normal vars, use double quotes to allow $VAR expansion + $escapedValue = escapeBashDoubleQuoted($env->real_value); + $envs->push($env->key.'='.$escapedValue); + + if (isDev()) { + $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); + $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}"); + $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); + } + } + } + } + + // Return the generated environment variables + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + } + + return $envs; + } + + private function save_buildtime_environment_variables() + { + // Generate build-time environment variables locally + $environment_variables = $this->generate_buildtime_environment_variables(); + + // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'), + 'hidden' => true, + ], + ); + } elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') { + // For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables + // This ensures the file exists when referenced in build commands + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'), + ] + ); + } } private function elixir_finetunes() @@ -1022,32 +1524,17 @@ private function elixir_finetunes() $envType = 'environment_variables_preview'; } $mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first(); - if ($mix_env) { - if ($mix_env->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set MIX_ENV environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $mix_env) { $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first(); - if ($secret_key_base) { - if ($secret_key_base->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set SECRET_KEY_BASE environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $secret_key_base) { $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first(); - if ($database_url) { - if ($database_url->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set DATABASE_URL environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $database_url) { $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } @@ -1067,7 +1554,6 @@ private function laravel_finetunes() $nixpacks_php_fallback_path = new EnvironmentVariable; $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; $nixpacks_php_fallback_path->value = '/index.php'; - $nixpacks_php_fallback_path->is_build_time = false; $nixpacks_php_fallback_path->resourceable_id = $this->application->id; $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; $nixpacks_php_fallback_path->save(); @@ -1076,7 +1562,6 @@ private function laravel_finetunes() $nixpacks_php_root_dir = new EnvironmentVariable; $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; $nixpacks_php_root_dir->value = '/app/public'; - $nixpacks_php_root_dir->is_build_time = false; $nixpacks_php_root_dir->resourceable_id = $this->application->id; $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; $nixpacks_php_root_dir->save(); @@ -1087,6 +1572,7 @@ private function laravel_finetunes() private function rolling_update() { + $this->checkForCancellation(); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->execute_remote_command( @@ -1241,13 +1727,19 @@ private function deploy_pull_request() $this->generate_nixpacks_confs(); } $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); + + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); - // $this->stop_running_container(); $this->rolling_update(); } @@ -1281,27 +1773,35 @@ private function create_workdir() } } - private function prepare_builder_image() + private function prepare_builder_image(bool $firstTry = true) { + $this->checkForCancellation(); $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); $helperImage = "{$helperImage}:{$settings->helper_version}"; // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); + + $env_flags = $this->generate_docker_env_flags_for_secrets(); if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); } - $runCommand = "docker run -d --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { if ($this->dockerConfigFileExists === 'OK') { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } - $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); + if ($firstTry) { + $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage"); + } else { + $this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.'); + } + $this->graceful_shutdown_container($this->deployment_uuid); $this->execute_remote_command( [ @@ -1315,6 +1815,18 @@ private function prepare_builder_image() $this->run_pre_deployment_command(); } + private function restart_builder_container_with_actual_commit() + { + // Stop and remove the current helper container + $this->graceful_shutdown_container($this->deployment_uuid); + + // Clear cached env_args to force regeneration with actual SOURCE_COMMIT value + $this->env_args = null; + + // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT) + $this->prepare_builder_image(firstTry: false); + } + private function deploy_to_additional_destinations() { if ($this->application->additional_networks->count() === 0) { @@ -1369,13 +1881,22 @@ private function set_coolify_variables() $fqdn = $this->preview->fqdn; } if (isset($fqdn)) { - $this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; - $url = str($fqdn)->replace('http://', '')->replace('https://', ''); - $this->coolify_variables .= "COOLIFY_URL={$url} "; + $url = Url::fromString($fqdn); + $fqdn = $url->getHost(); + $url = $url->withHost($fqdn)->withPort(null)->__toString(); + if ((int) $this->application->compose_parsing_version >= 3) { + $this->coolify_variables .= "COOLIFY_URL={$url} "; + $this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; + } else { + $this->coolify_variables .= "COOLIFY_URL={$fqdn} "; + $this->coolify_variables .= "COOLIFY_FQDN={$url} "; + } } if (isset($this->application->git_branch)) { $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; } + $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; + $this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} "; } private function check_git_if_build_needed() @@ -1383,8 +1904,8 @@ private function check_git_if_build_needed() if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) { $repository = githubApi($this->source, "repos/{$this->customRepository}"); $data = data_get($repository, 'data'); - if (isset($data->id)) { - $repository_project_id = $data->id; + $repository_project_id = data_get($data, 'id'); + if (isset($repository_project_id)) { if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) { $this->application->repository_project_id = $repository_project_id; $this->application->save(); @@ -1396,6 +1917,19 @@ private function check_git_if_build_needed() if ($this->pull_request_id !== 0) { $local_branch = "pull/{$this->pull_request_id}/head"; } + // Build an exact refspec for ls-remote so we don't match similarly named branches (e.g., changeset-release/main) + if ($this->pull_request_id === 0) { + $lsRemoteRef = "refs/heads/{$local_branch}"; + } else { + if ($this->git_type === 'github' || $this->git_type === 'gitea') { + $lsRemoteRef = "refs/pull/{$this->pull_request_id}/head"; + } elseif ($this->git_type === 'gitlab') { + $lsRemoteRef = "refs/merge-requests/{$this->pull_request_id}/head"; + } else { + // Fallback to the original value if provider-specific ref is unknown + $lsRemoteRef = $local_branch; + } + } $private_key = data_get($this->application, 'private_key.private_key'); if ($private_key) { $private_key = base64_encode($private_key); @@ -1410,7 +1944,7 @@ private function check_git_if_build_needed() executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -1418,25 +1952,49 @@ private function check_git_if_build_needed() } else { $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ], ); } if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) { - $this->commit = $this->saved_outputs->get('git_commit_sha')->before("\t"); - $this->application_deployment_queue->commit = $this->commit; - $this->application_deployment_queue->save(); + // Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings) + // Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines + // Note: Git warnings can be on the same line as the result (no newline) + $lsRemoteOutput = $this->saved_outputs->get('git_commit_sha'); + + // Find the part containing a tab (the actual ls-remote result) + // Handle cases where warning is on the same line as the result + if ($lsRemoteOutput->contains("\t")) { + // Get everything from the last occurrence of a valid commit SHA pattern before the tab + // A valid commit SHA is 40 hex characters + $output = $lsRemoteOutput->value(); + + // Extract the line with the tab (actual ls-remote result) + preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches); + + if (isset($matches[1])) { + $this->commit = $matches[1]; + $this->application_deployment_queue->commit = $this->commit; + $this->application_deployment_queue->save(); + } + } } $this->set_coolify_variables(); + + // Restart helper container with actual SOURCE_COMMIT value + if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') { + $this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.'); + $this->restart_builder_container_with_actual_commit(); + } } private function clone_repository() { $importCommands = $this->generate_git_import_commands(); $this->application_deployment_queue->addLogEntry("\n----------------------------------------"); - $this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->application->git_commit_sha}) to {$this->basedir}."); + $this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->commit}) to {$this->basedir}."); if ($this->pull_request_id !== 0) { $this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head."); } @@ -1486,6 +2044,7 @@ private function generate_nixpacks_confs() { $nixpacks_command = $this->nixpacks_build_cmd(); $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], @@ -1502,9 +2061,10 @@ private function generate_nixpacks_confs() if ($this->nixpacks_plan) { $this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}."); $this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}"); - $parsed = Toml::Parse($this->nixpacks_plan); + $parsed = json_decode($this->nixpacks_plan, true); // Do any modifications here + // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); @@ -1530,6 +2090,15 @@ private function generate_nixpacks_confs() if ($this->nixpacks_type === 'elixir') { $this->elixir_finetunes(); } + if ($this->nixpacks_type === 'node') { + // Check if NIXPACKS_NODE_VERSION is set + $variables = data_get($parsed, 'variables', []); + if (! isset($variables['NIXPACKS_NODE_VERSION'])) { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.'); + $this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.'); + } + } $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); $this->nixpacks_plan_json = collect($parsed); $this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true); @@ -1545,7 +2114,7 @@ private function generate_nixpacks_confs() private function nixpacks_build_cmd() { $this->generate_nixpacks_env_variables(); - $nixpacks_command = "nixpacks plan -f toml {$this->env_nixpacks_args}"; + $nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}"; if ($this->application->build_command) { $nixpacks_command .= " --build-cmd \"{$this->application->build_command}\""; } @@ -1577,6 +2146,12 @@ private function generate_nixpacks_env_variables() } } + // Add COOLIFY_* environment variables to Nixpacks build context + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_nixpacks_args->push("--env {$key}={$value}"); + }); + $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } @@ -1669,45 +2244,33 @@ private function generate_env_variables() { $this->env_args = collect([]); $this->env_args->put('SOURCE_COMMIT', $this->commit); + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_args->put($key, $value); + }); + + // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - foreach ($this->application->build_environment_variables as $env) { + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } else { - foreach ($this->application->build_environment_variables_preview as $env) { + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } @@ -1715,17 +2278,12 @@ private function generate_env_variables() private function generate_compose_file() { + $this->checkForCancellation(); $this->create_workdir(); $ports = $this->application->main_port(); - $onlyPort = null; - if (count($ports) > 0) { - $onlyPort = $ports[0]; - } $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - // $environment_variables = $this->generate_environment_variables($ports); - $this->save_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -1794,9 +2352,8 @@ private function generate_compose_file() ], ], ]; - if (! is_null($this->env_filename)) { - $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; - } + // Always use .env file + $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; $docker_compose['services'][$this->container_name]['healthcheck'] = [ 'test' => [ 'CMD-SHELL', @@ -1965,7 +2522,7 @@ private function generate_local_persistent_volumes() $volume_name = $persistentStorage->name; } if ($this->pull_request_id !== 0) { - $volume_name = $volume_name.'-pr-'.$this->pull_request_id; + $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } @@ -1983,7 +2540,7 @@ private function generate_local_persistent_volumes_only_volume_names() $name = $persistentStorage->name; if ($this->pull_request_id !== 0) { - $name = $name.'-pr-'.$this->pull_request_id; + $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } $local_persistent_volumes_names[$name] = [ @@ -2031,16 +2588,85 @@ private function pull_latest_image($image) ); } + private function build_static_image() + { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.'); + if ($this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} + WORKDIR /usr/share/nginx/html/ + LABEL coolify.deploymentId={$this->deployment_uuid} + COPY . . + RUN rm -f /usr/share/nginx/html/nginx.conf + RUN rm -f /usr/share/nginx/html/Dockerfile + RUN rm -f /usr/share/nginx/html/docker-compose.yaml + RUN rm -f /usr/share/nginx/html/.env + COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + } else { + $nginx_config = base64_encode(defaultNginxConfiguration()); + } + } + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->application_deployment_queue->addLogEntry('Building docker image completed.'); + } + + /** + * Wrap a docker build command with environment export from /artifacts/build-time.env + * This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL) + * + * @param string $build_command The docker build command to wrap + * @return string The wrapped command with export statement + */ + private function wrap_build_command_with_env_export(string $build_command): string + { + return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}"; + } + private function build_image() { - // Add Coolify related variables to the build args - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { - $this->build_args->push("--build-arg '{$key}'"); - }); - - $this->build_args = $this->build_args->implode(' '); + // Add Coolify related variables to the build args/secrets + if ($this->dockerBuildkitSupported) { + // Coolify variables are already included in the secrets from generate_build_env_variables + // build_secrets is already a string at this point + } else { + // Traditional build args approach - generate COOLIFY_ variables locally + // Generate COOLIFY_ variables locally for build args + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->build_args->push("--build-arg '{$key}'"); + }); + $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection + ? $this->build_args->implode(' ') + : (string) $this->build_args; + } $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { @@ -2053,103 +2679,134 @@ private function build_image() $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); } - if ($this->application->settings->is_static || $this->application->build_pack === 'static') { + if ($this->application->settings->is_static) { if ($this->application->static_image) { $this->pull_latest_image($this->application->static_image); $this->application_deployment_queue->addLogEntry('Continuing with the building process.'); } - if ($this->application->build_pack === 'static') { - $dockerfile = base64_encode("FROM {$this->application->static_image} -WORKDIR /usr/share/nginx/html/ -LABEL coolify.deploymentId={$this->deployment_uuid} -COPY . . -RUN rm -f /usr/share/nginx/html/nginx.conf -RUN rm -f /usr/share/nginx/html/Dockerfile -RUN rm -f /usr/share/nginx/html/docker-compose.yaml -RUN rm -f /usr/share/nginx/html/.env -COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + if ($this->application->build_pack === 'nixpacks') { + $this->nixpacks_plan = base64_encode($this->nixpacks_plan); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); + if ($this->force_rebuild) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + ray($build_command); } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + } + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } - } else { - if ($this->application->build_pack === 'nixpacks') { - $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); - if ($this->force_rebuild) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}"; - } else { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}"; - } - $base64_build_command = base64_encode($build_command); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); - } else { + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + } else { + // Dockerfile buildpack + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + } + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + } + } else { + // Traditional build with args + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); } - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); } - $dockerfile = base64_encode("FROM {$this->application->static_image} + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + } + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} -COPY --from=$this->build_image_name /app/{$this->application->publish_directory} . +COPY --from=$this->build_image_name /app{$publishDir} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); - } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); - } + $nginx_config = base64_encode(defaultNginxConfiguration()); } } - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -2174,10 +2831,22 @@ private function build_image() } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + // Traditional build with args + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2202,14 +2871,44 @@ private function build_image() $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2228,13 +2927,35 @@ private function build_image() ); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Dockerfile buildpack + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + // Use BuildKit with secrets + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } } else { - $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Traditional build with args + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } } + $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), @@ -2255,9 +2976,10 @@ private function build_image() $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } - private function graceful_shutdown_container(string $containerName, int $timeout = 30) + private function graceful_shutdown_container(string $containerName) { try { + $timeout = isDev() ? 1 : 30; $this->execute_remote_command( ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] @@ -2277,7 +2999,7 @@ private function stop_running_container(bool $force = false) $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { $containers = $containers->filter(function ($container) { - return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id; + return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id); }); } $containers->each(function ($container) { @@ -2320,6 +3042,50 @@ private function start_by_compose_file() $this->application_deployment_queue->addLogEntry('New container started.'); } + private function analyzeBuildTimeVariables($variables) + { + $userDefinedVariables = collect([]); + + $dbVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + ->where('is_buildtime', true) + ->pluck('key') + : $this->application->environment_variables_preview() + ->where('is_buildtime', true) + ->pluck('key'); + + foreach ($variables as $key => $value) { + if ($dbVariables->contains($key)) { + $userDefinedVariables->put($key, $value); + } + } + + if ($userDefinedVariables->isEmpty()) { + return; + } + + $variablesArray = $userDefinedVariables->toArray(); + $warnings = self::analyzeBuildVariables($variablesArray); + + if (empty($warnings)) { + return; + } + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + foreach ($warnings as $warning) { + $messages = self::formatBuildWarning($warning); + foreach ($messages as $message) { + $this->application_deployment_queue->addLogEntry($message, type: 'warning'); + } + $this->application_deployment_queue->addLogEntry(''); + } + + // Add general advice + $this->application_deployment_queue->addLogEntry('💡 Tips to resolve build issues:', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 1. Set these variables as "Runtime only" in the environment variables settings', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 2. Use different values for build-time (e.g., NODE_ENV=development for build)', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 3. Consider using multi-stage Docker builds to separate build and runtime environments', type: 'info'); + } + private function generate_build_env_variables() { if ($this->application->build_pack === 'nixpacks') { @@ -2328,44 +3094,482 @@ private function generate_build_env_variables() $this->generate_env_variables(); $variables = collect([])->merge($this->env_args); } + // Analyze build variables for potential issues + if ($variables->isNotEmpty()) { + $this->analyzeBuildTimeVariables($variables); + } - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + $this->generate_build_secrets($variables); + $this->build_args = ''; + } else { + $secrets_hash = ''; + if ($variables->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($variables); + } - return "--build-arg {$key}={$value}"; + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + + // Map variables to include is_multiline flag + $vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; + }); + + $this->build_args = generateDockerBuildArgs($vars_with_metadata); + + if ($secrets_hash) { + $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } + } + } + + private function generate_docker_env_flags_for_secrets() + { + // Only generate env flags if build secrets are enabled + if (! $this->application->settings->use_build_secrets) { + return ''; + } + + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; + + if ($variables->isEmpty()) { + return ''; + } + + $secrets_hash = $this->generate_secrets_hash($variables); + + // Get database env vars to check for multiline flag + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + + // Map to simple array format for the helper function + $vars_array = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; }); + + $env_flags = generateDockerEnvFlags($vars_array); + $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; + + return $env_flags; + } + + private function generate_build_secrets(Collection $variables) + { + if ($variables->isEmpty()) { + $this->build_secrets = ''; + + return; + } + + $this->build_secrets = $variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); + + $this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + } + + private function generate_secrets_hash($variables) + { + if (! $this->secrets_hash_key) { + $this->secrets_hash_key = bin2hex(random_bytes(32)); + } + + if ($variables instanceof Collection) { + $secrets_string = $variables + ->mapWithKeys(function ($value, $key) { + return [$key => $value]; + }) + ->sortKeys() + ->map(function ($value, $key) { + return "{$key}={$value}"; + }) + ->implode('|'); + } else { + $secrets_string = $variables + ->map(function ($env) { + return "{$env->key}={$env->real_value}"; + }) + ->sort() + ->implode('|'); + } + + return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key); } private function add_build_env_variables_to_dockerfile() { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'save' => 'dockerfile', - ]); - $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - if ($this->pull_request_id === 0) { - foreach ($this->application->build_environment_variables as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, "ARG {$env->key}"); - } else { - $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}"); + if ($this->dockerBuildkitSupported) { + // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'save' => 'dockerfile', + 'ignore_errors' => true, + ]); + $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); + if ($this->pull_request_id === 0) { + // Only add environment variables that are available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } + } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + foreach ($coolify_vars as $arg) { + $dockerfile->splice(1, 0, [$arg]); + } + } + } else { + // Only add preview environment variables that are available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } + } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + foreach ($coolify_vars as $arg) { + $dockerfile->splice(1, 0, [$arg]); + } } } - } else { - foreach ($this->application->build_environment_variables_preview as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, "ARG {$env->key}"); - } else { - $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}"); + + if ($envs->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($envs); + $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]); + } + + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'ignore_errors' => true, + ]); + } + } + + private function modify_dockerfile_for_secrets($dockerfile_path) + { + // Only process if build secrets are enabled and we have secrets to mount + if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + return; + } + + // Read the Dockerfile + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"), + 'hidden' => true, + 'save' => 'dockerfile_content', + ]); + + $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n"); + + // Add BuildKit syntax directive if not present + if (! str_starts_with($dockerfile->first(), '# syntax=')) { + $dockerfile->prepend('# syntax=docker/dockerfile:1'); + } + + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; + if ($variables->isEmpty()) { + return; + } + + // Generate mount strings for all secrets + $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); + + // Add mount for the secrets hash to ensure cache invalidation + $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + + $modified = false; + $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { + $trimmed = ltrim($line); + + // Skip lines that already have secret mounts or are not RUN commands + if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) { + return $line; + } + + // Add mount strings to RUN command + $originalCommand = trim(substr($trimmed, 3)); + $modified = true; + + return "RUN {$mountStrings} {$originalCommand}"; + }); + + if ($modified) { + // Write the modified Dockerfile back + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), + 'hidden' => true, + ]); + } + } + + private function modify_dockerfiles_for_compose($composeFile) + { + if ($this->application->build_pack !== 'dockercompose') { + return; + } + + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; + + if ($variables->isEmpty()) { + $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); + + return; + } + + $services = data_get($composeFile, 'services', []); + + foreach ($services as $serviceName => $service) { + if (! isset($service['build'])) { + continue; + } + + $context = '.'; + $dockerfile = 'Dockerfile'; + + if (is_string($service['build'])) { + $context = $service['build']; + } elseif (is_array($service['build'])) { + $context = data_get($service['build'], 'context', '.'); + $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); + } + + $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); + if (str_starts_with($dockerfilePath, './')) { + $dockerfilePath = substr($dockerfilePath, 2); + } + if (str_starts_with($dockerfilePath, '/')) { + $dockerfilePath = substr($dockerfilePath, 1); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + 'hidden' => true, + 'save' => 'dockerfile_check_'.$serviceName, + ]); + + if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); + + continue; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + 'hidden' => true, + 'save' => 'dockerfile_content_'.$serviceName, + ]); + + $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); + if (! $dockerfileContent) { + continue; + } + + $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); + + $fromIndices = []; + $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) { + if (str($line)->trim()->startsWith('FROM')) { + $fromIndices[] = $index; + } + }); + + if (empty($fromIndices)) { + $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); + + continue; + } + + $isMultiStage = count($fromIndices) > 1; + + $argsToAdd = collect([]); + foreach ($variables as $key => $value) { + $argsToAdd->push("ARG {$key}"); + } + + if ($argsToAdd->isEmpty()) { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); + + continue; + } + + $totalAdded = 0; + $offset = 0; + + foreach ($fromIndices as $stageIndex => $fromIndex) { + $adjustedIndex = $fromIndex + $offset; + + $stageStart = $adjustedIndex + 1; + $stageEnd = isset($fromIndices[$stageIndex + 1]) + ? $fromIndices[$stageIndex + 1] + $offset + : $dockerfile_lines->count(); + + $existingStageArgs = collect([]); + for ($i = $stageStart; $i < $stageEnd; $i++) { + $line = $dockerfile_lines->get($i); + if (! $line || ! str($line)->trim()->startsWith('ARG')) { + break; + } + $parts = explode(' ', trim($line), 2); + if (count($parts) >= 2) { + $argPart = $parts[1]; + $keyValue = explode('=', $argPart, 2); + $existingStageArgs->push($keyValue[0]); + } + } + + $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { + $key = str($arg)->after('ARG ')->trim()->toString(); + + return ! $existingStageArgs->contains($key); + }); + + if ($stageArgsToAdd->isNotEmpty()) { + $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); + $totalAdded += $stageArgsToAdd->count(); + $offset += $stageArgsToAdd->count(); + } + } + + if ($totalAdded > 0) { + $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + 'hidden' => true, + ]); + + $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; + $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); + } else { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); + } + + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; + $this->modify_dockerfile_for_secrets($fullDockerfilePath); + $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); + } + } + } + + private function add_build_secrets_to_compose($composeFile) + { + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; + + if ($variables->isEmpty()) { + return $composeFile; + } + + $secrets = []; + foreach ($variables as $key => $value) { + $secrets[$key] = [ + 'environment' => $key, + ]; + } + + $services = data_get($composeFile, 'services', []); + foreach ($services as $serviceName => &$service) { + if (isset($service['build'])) { + if (is_string($service['build'])) { + $service['build'] = [ + 'context' => $service['build'], + ]; + } + if (! isset($service['build']['secrets'])) { + $service['build']['secrets'] = []; + } + foreach ($variables as $key => $value) { + if (! in_array($key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $key; + } } } } - $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); + + $composeFile['services'] = $services; + $existingSecrets = data_get($composeFile, 'secrets', []); + if ($existingSecrets instanceof \Illuminate\Support\Collection) { + $existingSecrets = $existingSecrets->toArray(); + } + $composeFile['secrets'] = array_replace($existingSecrets, $secrets); + + $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).'); + + return $composeFile; } private function run_pre_deployment_command() @@ -2433,27 +3637,44 @@ private function run_post_deployment_command() throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } + /** + * Check if the deployment was cancelled and abort if it was + */ + private function checkForCancellation(): void + { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + private function next(string $status) { - queue_next_deployment($this->application); + // Refresh to get latest status + $this->application_deployment_queue->refresh(); // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else - if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value || - $this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { + $this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview)); + return; } + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + // Job was cancelled, stop execution + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); + } $this->application_deployment_queue->update([ 'status' => $status, ]); - if ($status === ApplicationDeploymentStatus::FAILED->value) { - $this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview)); - - return; - } + queue_next_deployment($this->application); if ($status === ApplicationDeploymentStatus::FINISHED->value) { + event(new ApplicationConfigurationChanged($this->application->team()->id)); + if (! $this->only_this_server) { $this->deploy_to_additional_destinations(); } @@ -2473,8 +3694,8 @@ public function failed(Throwable $exception): void $code = $exception->getCode(); if ($code !== 69420) { // 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one - if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - // do not remove already running container + if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0) { + // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->execute_remote_command( diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index ef8e6efb6..d2e3cc964 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -35,21 +35,25 @@ public function handle() if ($this->application->is_public_repository()) { return; } + + $serviceName = $this->application->name; + if ($this->status === ProcessStatus::CLOSED) { $this->delete_comment(); return; - } elseif ($this->status === ProcessStatus::IN_PROGRESS) { - $this->body = "The preview deployment is in progress. 🟡\n\n"; - } elseif ($this->status === ProcessStatus::FINISHED) { - $this->body = "The preview deployment is ready. 🟢\n\n"; - if ($this->preview->fqdn) { - $this->body .= "[Open Preview]({$this->preview->fqdn}) | "; - } - } elseif ($this->status === ProcessStatus::ERROR) { - $this->body = "The preview deployment failed. 🔴\n\n"; } - $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + + match ($this->status) { + ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n", + ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n", + ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''), + ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n", + ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n", + ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n", + ProcessStatus::CLOSED => '', // Already handled above, but included for completeness + }; + $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; $this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n"; $this->body .= 'Last updated at: '.now()->toDateTimeString().' CET'; diff --git a/app/Jobs/CleanupInstanceStuffsJob.php b/app/Jobs/CleanupInstanceStuffsJob.php index 53e344daf..011c58639 100644 --- a/app/Jobs/CleanupInstanceStuffsJob.php +++ b/app/Jobs/CleanupInstanceStuffsJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\TeamInvitation; +use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -23,13 +24,14 @@ public function __construct() {} public function middleware(): array { - return [(new WithoutOverlapping('cleanup-instance-stuffs'))->dontRelease()]; + return [(new WithoutOverlapping('cleanup-instance-stuffs'))->expireAfter(60)->dontRelease()]; } public function handle(): void { try { $this->cleanupInvitationLink(); + $this->cleanupExpiredEmailChangeRequests(); } catch (\Throwable $e) { Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); } @@ -42,4 +44,15 @@ private function cleanupInvitationLink() $item->isValid(); } } + + private function cleanupExpiredEmailChangeRequests() + { + User::whereNotNull('email_change_code_expires_at') + ->where('email_change_code_expires_at', '<', now()) + ->update([ + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + } } diff --git a/app/Jobs/ContainerStatusJob.php b/app/Jobs/ContainerStatusJob.php deleted file mode 100644 index 22ae06ebd..000000000 --- a/app/Jobs/ContainerStatusJob.php +++ /dev/null @@ -1,31 +0,0 @@ -server); - } -} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 5e8dc581a..11da6fac1 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -15,6 +15,7 @@ use App\Models\Team; use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; +use App\Notifications\Database\BackupSuccessWithS3Warning; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -23,6 +24,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Str; +use Throwable; +use Visus\Cuid2\Cuid2; class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { @@ -52,13 +55,26 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?string $backup_output = null; + public ?string $error_output = null; + + public bool $s3_uploaded = false; + public ?string $postgres_password = null; + public ?string $mongo_root_username = null; + + public ?string $mongo_root_password = null; + public ?S3Storage $s3 = null; + public $timeout = 3600; + + public ?string $backup_log_uuid = null; + public function __construct(public ScheduledDatabaseBackup $backup) { $this->onQueue('high'); + $this->timeout = $backup->timeout; } public function handle(): void @@ -189,6 +205,36 @@ public function handle(): void throw new \Exception('MARIADB_DATABASE or MYSQL_DATABASE not found'); } } + } elseif (str($databaseType)->contains('mongo')) { + $databasesToBackup = ['*']; + $this->container_name = "{$this->database->name}-$serviceUuid"; + $this->directory_name = $serviceName.'-'.$this->container_name; + + // Try to extract MongoDB credentials from environment variables + try { + $commands = []; + $commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_"; + $envs = instant_remote_process($commands, $this->server); + + if (filled($envs)) { + $envs = str($envs)->explode("\n"); + $rootPassword = $envs->filter(function ($env) { + return str($env)->startsWith('MONGO_INITDB_ROOT_PASSWORD='); + })->first(); + if ($rootPassword) { + $this->mongo_root_password = str($rootPassword)->after('MONGO_INITDB_ROOT_PASSWORD=')->value(); + } + $rootUsername = $envs->filter(function ($env) { + return str($env)->startsWith('MONGO_INITDB_ROOT_USERNAME='); + })->first(); + if ($rootUsername) { + $this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value(); + } + } + + } catch (\Throwable $e) { + // Continue without env vars - will be handled in backup_standalone_mongodb method + } } } else { $databaseName = str($this->database->name)->slug()->value(); @@ -200,7 +246,7 @@ public function handle(): void if (blank($databasesToBackup)) { if (str($databaseType)->contains('postgres')) { $databasesToBackup = [$this->database->postgres_db]; - } elseif (str($databaseType)->contains('mongodb')) { + } elseif (str($databaseType)->contains('mongo')) { $databasesToBackup = ['*']; } elseif (str($databaseType)->contains('mysql')) { $databasesToBackup = [$this->database->mysql_database]; @@ -214,10 +260,13 @@ public function handle(): void // Format: db1,db2,db3 $databasesToBackup = explode(',', $databasesToBackup); $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mongodb')) { + } elseif (str($databaseType)->contains('mongo')) { // Format: db1:collection1,collection2|db2:collection3,collection4 - $databasesToBackup = explode('|', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); + // Only explode if it's a string, not if it's already an array + if (is_string($databasesToBackup)) { + $databasesToBackup = explode('|', $databasesToBackup); + $databasesToBackup = array_map('trim', $databasesToBackup); + } } elseif (str($databaseType)->contains('mysql')) { // Format: db1,db2,db3 $databasesToBackup = explode(',', $databasesToBackup); @@ -238,7 +287,22 @@ public function handle(): void $this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip"; } foreach ($databasesToBackup as $database) { + // Generate unique UUID for each database backup execution + $attempts = 0; + do { + $this->backup_log_uuid = (string) new Cuid2; + $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); + $attempts++; + if ($attempts >= 3 && $exists) { + throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts'); + } + } while ($exists); + $size = 0; + $localBackupSucceeded = false; + $s3UploadError = null; + + // Step 1: Create local backup try { if (str($databaseType)->contains('postgres')) { $this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -247,12 +311,14 @@ public function handle(): void } $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_postgresql($database); - } elseif (str($databaseType)->contains('mongodb')) { + } elseif (str($databaseType)->contains('mongo')) { if ($database === '*') { $database = 'all'; $databaseName = 'all'; @@ -266,9 +332,11 @@ public function handle(): void $this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz'; $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, 'database_name' => $databaseName, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { @@ -278,9 +346,11 @@ public function handle(): void } $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { @@ -290,36 +360,82 @@ public function handle(): void } $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mariadb($database); } else { throw new \Exception('Unsupported database type'); } + $size = $this->calculate_size(); - if ($this->backup->save_s3) { - $this->upload_to_s3(); + + // Verify local backup succeeded + if ($size > 0) { + $localBackupSucceeded = true; + } else { + throw new \Exception('Local backup file is empty or was not created'); } - - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); - - $this->backup_log->update([ - 'status' => 'success', - 'message' => $this->backup_output, - 'size' => $size, - ]); } catch (\Throwable $e) { + // Local backup failed if ($this->backup_log) { $this->backup_log->update([ 'status' => 'failed', - 'message' => $this->backup_output, + 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), 'size' => $size, 'filename' => null, + 's3_uploaded' => null, ]); } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database)); + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + + continue; + } + + // Step 2: Upload to S3 if enabled (independent of local backup) + $localStorageDeleted = false; + if ($this->backup->save_s3 && $localBackupSucceeded) { + try { + $this->upload_to_s3(); + + // If local backup is disabled, delete the local file immediately after S3 upload + if ($this->backup->disable_local_backup) { + deleteBackupsLocally($this->backup_location, $this->server); + $localStorageDeleted = true; + } + } catch (\Throwable $e) { + // S3 upload failed but local backup succeeded + $s3UploadError = $e->getMessage(); + } + } + + // Step 3: Update status and send notifications based on results + if ($localBackupSucceeded) { + $message = $this->backup_output; + + if ($s3UploadError) { + $message = $message + ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError + : 'Warning: S3 upload failed: '.$s3UploadError; + } + + $this->backup_log->update([ + 'status' => 'success', + 'message' => $message, + 'size' => $size, + 's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null, + 'local_storage_deleted' => $localStorageDeleted, + ]); + + // Send appropriate notification + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); + } else { + $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + } } } if ($this->backup_log && $this->backup_log->status === 'success') { @@ -343,6 +459,17 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi { try { $url = $this->database->internal_db_url; + if (blank($url)) { + // For service-based MongoDB, try to build URL from environment variables + if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) { + // Use container name instead of server IP for service-based MongoDB + $url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017"; + } else { + // If no environment variables are available, throw an exception + throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.'); + } + } + \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); if ($databaseWithCollections === 'all') { $commands[] = 'mkdir -p '.$this->backup_dir; if (str($this->database->image)->startsWith('mongo:4')) { @@ -379,7 +506,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -405,7 +532,7 @@ private function backup_standalone_postgresql(string $database): void $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -425,7 +552,7 @@ private function backup_standalone_mysql(string $database): void $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -445,7 +572,7 @@ private function backup_standalone_mariadb(string $database): void $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -459,6 +586,15 @@ private function add_to_backup_output($output): void } } + private function add_to_error_output($output): void + { + if ($this->error_output) { + $this->error_output = $this->error_output."\n".$output; + } else { + $this->error_output = $output; + } + } + private function calculate_size() { return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); @@ -484,32 +620,33 @@ private function upload_to_s3(): void $fullImageName = $this->getFullImageName(); - $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false); + $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false); + instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false); } if (isDev()) { if ($this->database->name === 'coolify-db') { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } else { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } } else { - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc config host add temporary {$endpoint} $key \"$secret\""; - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); - $this->add_to_backup_output('Uploaded to S3.'); + $this->s3_uploaded = true; } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->s3_uploaded = false; + $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup->uuid}"; + $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; instant_remote_process([$command], $this->server); } } @@ -522,4 +659,18 @@ private function getFullImageName(): string return "{$helperImage}:{$latestVersion}"; } + + public function failed(?Throwable $exception): void + { + $log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first(); + + if ($log) { + $log->update([ + 'status' => 'failed', + 'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'), + 'size' => 0, + 'filename' => null, + ]); + } + } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 408bb2a7a..b9fbebcc9 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -8,6 +8,7 @@ use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Service; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; @@ -30,11 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( - public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, - public bool $deleteConfigurations = true, + public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, public bool $deleteVolumes = true, - public bool $dockerCleanup = true, - public bool $deleteConnectedNetworks = true + public bool $deleteConnectedNetworks = true, + public bool $deleteConfigurations = true, + public bool $dockerCleanup = true ) { $this->onQueue('high'); } @@ -42,9 +43,16 @@ public function __construct( public function handle() { try { + // Handle ApplicationPreview instances separately + if ($this->resource instanceof ApplicationPreview) { + $this->deleteApplicationPreview(); + + return; + } + switch ($this->resource->type()) { case 'application': - StopApplication::run($this->resource, previewDeployments: true); + StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup); break; case 'standalone-postgresql': case 'standalone-redis': @@ -54,11 +62,11 @@ public function handle() case 'standalone-keydb': case 'standalone-dragonfly': case 'standalone-clickhouse': - StopDatabase::run($this->resource, true); + StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup); break; case 'service': - StopService::run($this->resource, true); - DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); + StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); + DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup); return; } @@ -70,7 +78,7 @@ public function handle() $this->resource->deleteVolumes(); $this->resource->persistentStorages()->delete(); } - $this->resource->fileStorages()->delete(); + $this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag $isDatabase = $this->resource instanceof StandalonePostgresql || $this->resource instanceof StandaloneRedis @@ -98,10 +106,61 @@ public function handle() if ($this->dockerCleanup) { $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); if ($server) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } } Artisan::queue('cleanup:stucked-resources'); } } + + private function deleteApplicationPreview() + { + $application = $this->resource->application; + $server = $application->destination->server; + $pull_request_id = $this->resource->pull_request_id; + + // Ensure the preview is soft deleted (may already be done in Livewire component) + if (! $this->resource->trashed()) { + $this->resource->delete(); + } + + try { + if ($server->isSwarm()) { + instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server); + } else { + $containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray(); + $this->stopPreviewContainers($containers, $server); + } + } catch (\Throwable $e) { + // Log the error but don't fail the job + ray('Error stopping preview containers: '.$e->getMessage()); + } + + // Finally, force delete to trigger resource cleanup + $this->resource->forceDelete(); + } + + private function stopPreviewContainers(array $containers, $server, int $timeout = 30) + { + if (empty($containers)) { + return; + } + + $containerNames = []; + foreach ($containers as $container) { + $containerNames[] = str_replace('/', '', $container['Names']); + } + + $containerList = implode(' ', array_map('escapeshellarg', $containerNames)); + $commands = [ + "docker stop --time=$timeout $containerList", + "docker rm -f $containerList", + ]; + + instant_remote_process( + command: $commands, + server: $server, + throwError: false + ); + } } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 05a4aa8de..f3f3a2ae4 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -31,10 +31,15 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue public function middleware(): array { - return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; + return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()]; } - public function __construct(public Server $server, public bool $manualCleanup = false) {} + public function __construct( + public Server $server, + public bool $manualCleanup = false, + public bool $deleteUnusedVolumes = false, + public bool $deleteUnusedNetworks = false + ) {} public function handle(): void { @@ -50,7 +55,11 @@ public function handle(): void $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { - $cleanup_log = CleanupDocker::run(server: $this->server); + $cleanup_log = CleanupDocker::run( + server: $this->server, + deleteUnusedVolumes: $this->deleteUnusedVolumes, + deleteUnusedNetworks: $this->deleteUnusedNetworks + ); $usageAfter = $this->server->getDiskUsage(); $message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; @@ -67,7 +76,11 @@ public function handle(): void } if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) { - $cleanup_log = CleanupDocker::run(server: $this->server); + $cleanup_log = CleanupDocker::run( + server: $this->server, + deleteUnusedVolumes: $this->deleteUnusedVolumes, + deleteUnusedNetworks: $this->deleteUnusedNetworks + ); $message = 'Docker cleanup job executed successfully, but no disk usage could be determined.'; $this->execution_log->update([ @@ -81,7 +94,11 @@ public function handle(): void } if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { - $cleanup_log = CleanupDocker::run(server: $this->server); + $cleanup_log = CleanupDocker::run( + server: $this->server, + deleteUnusedVolumes: $this->deleteUnusedVolumes, + deleteUnusedNetworks: $this->deleteUnusedNetworks + ); $usageAfter = $this->server->getDiskUsage(); $diskSaved = $this->usageBefore - $usageAfter; diff --git a/app/Jobs/PullChangelog.php b/app/Jobs/PullChangelog.php new file mode 100644 index 000000000..052e6d557 --- /dev/null +++ b/app/Jobs/PullChangelog.php @@ -0,0 +1,126 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + // Fetch from CDN instead of GitHub API to avoid rate limits + $cdnUrl = config('constants.coolify.releases_url'); + + $response = Http::retry(3, 1000) + ->timeout(30) + ->get($cdnUrl); + + if ($response->successful()) { + $releases = $response->json(); + + // Limit to 10 releases for processing (same as before) + $releases = array_slice($releases, 0, 10); + + $changelog = $this->transformReleasesToChangelog($releases); + + // Group entries by month and save them + $this->saveChangelogEntries($changelog); + } else { + // Log error instead of sending notification + Log::error('PullChangelogFromGitHub: Failed to fetch from CDN', [ + 'status' => $response->status(), + 'url' => $cdnUrl, + ]); + } + } catch (\Throwable $e) { + // Log error instead of sending notification + Log::error('PullChangelogFromGitHub: Exception occurred', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + private function transformReleasesToChangelog(array $releases): array + { + $entries = []; + + foreach ($releases as $release) { + // Skip drafts and pre-releases if desired + if ($release['draft']) { + continue; + } + + $publishedAt = Carbon::parse($release['published_at']); + + $entry = [ + 'tag_name' => $release['tag_name'], + 'title' => $release['name'] ?: $release['tag_name'], + 'content' => $release['body'] ?: 'No release notes available.', + 'published_at' => $publishedAt->toISOString(), + ]; + + $entries[] = $entry; + } + + return $entries; + } + + private function saveChangelogEntries(array $entries): void + { + // Create changelogs directory if it doesn't exist + $changelogsDir = base_path('changelogs'); + if (! File::exists($changelogsDir)) { + File::makeDirectory($changelogsDir, 0755, true); + } + + // Group entries by year-month + $groupedEntries = []; + foreach ($entries as $entry) { + $date = Carbon::parse($entry['published_at']); + $monthKey = $date->format('Y-m'); + + if (! isset($groupedEntries[$monthKey])) { + $groupedEntries[$monthKey] = []; + } + + $groupedEntries[$monthKey][] = $entry; + } + + // Save each month's entries to separate files + foreach ($groupedEntries as $month => $monthEntries) { + // Sort entries by published date (newest first) + usort($monthEntries, function ($a, $b) { + return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp; + }); + + $monthData = [ + 'entries' => $monthEntries, + 'last_updated' => now()->toISOString(), + ]; + + $filePath = base_path("changelogs/{$month}.json"); + File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + } +} diff --git a/app/Jobs/PullTemplatesFromCDN.php b/app/Jobs/PullTemplatesFromCDN.php index 9a4c991bc..7e6b2e21a 100644 --- a/app/Jobs/PullTemplatesFromCDN.php +++ b/app/Jobs/PullTemplatesFromCDN.php @@ -31,7 +31,7 @@ public function handle(): void $response = Http::retry(3, 1000)->get(config('constants.services.official')); if ($response->successful()) { $services = $response->json(); - File::put(base_path('templates/service-templates.json'), json_encode($services)); + File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services)); } else { send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body()); } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 93b203fcb..7726c2c73 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -9,7 +9,6 @@ use App\Actions\Server\StartLogDrain; use App\Actions\Shared\ComplexStatusCheck; use App\Models\Application; -use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -22,8 +21,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use Laravel\Horizon\Contracts\Silenced; -class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue +class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -65,13 +65,15 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue public Collection $foundApplicationPreviewsIds; + public Collection $applicationContainerStatuses; + public bool $foundProxy = false; public bool $foundLogDrainContainer = false; public function middleware(): array { - return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; + return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()]; } public function backoff(): int @@ -87,6 +89,7 @@ public function __construct(public Server $server, public $data) $this->foundServiceApplicationIds = collect(); $this->foundApplicationPreviewsIds = collect(); $this->foundServiceDatabaseIds = collect(); + $this->applicationContainerStatuses = collect(); $this->allApplicationIds = collect(); $this->allDatabaseUuids = collect(); $this->allTcpProxyUuids = collect(); @@ -122,7 +125,9 @@ public function handle() $this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) { return $application->additional_servers->count() > 0; }); - $this->allApplicationPreviewsIds = $this->previews->pluck('id'); + $this->allApplicationPreviewsIds = $this->previews->map(function ($preview) { + return $preview->application_id.':'.$preview->pull_request_id; + }); $this->allDatabaseUuids = $this->databases->pluck('uuid'); $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); $this->services->each(function ($service) { @@ -147,18 +152,26 @@ public function handle() } if ($labels->has('coolify.applicationId')) { $applicationId = $labels->get('coolify.applicationId'); - $pullRequestId = data_get($labels, 'coolify.pullRequestId', '0'); + $pullRequestId = $labels->get('coolify.pullRequestId', '0'); try { if ($pullRequestId === '0') { if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) { $this->foundApplicationIds->push($applicationId); } - $this->updateApplicationStatus($applicationId, $containerStatus); - } else { - if ($this->allApplicationPreviewsIds->contains($applicationId) && $this->isRunning($containerStatus)) { - $this->foundApplicationPreviewsIds->push($applicationId); + // Store container status for aggregation + if (! $this->applicationContainerStatuses->has($applicationId)) { + $this->applicationContainerStatuses->put($applicationId, collect()); } - $this->updateApplicationPreviewStatus($applicationId, $containerStatus); + $containerName = $labels->get('com.docker.compose.service'); + if ($containerName) { + $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); + } + } else { + $previewKey = $applicationId.':'.$pullRequestId; + if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) { + $this->foundApplicationPreviewsIds->push($previewKey); + } + $this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus); } } catch (\Exception $e) { } @@ -202,27 +215,110 @@ public function handle() $this->updateAdditionalServersStatus(); + // Aggregate multi-container application statuses + $this->aggregateMultiContainerStatuses(); + $this->checkLogDrainContainer(); } + private function aggregateMultiContainerStatuses() + { + if ($this->applicationContainerStatuses->isEmpty()) { + return; + } + + foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + continue; + } + + // Parse docker compose to check for excluded containers + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + // Check if container should be excluded + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + // Filter out excluded containers + $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) { + return ! $excludedContainers->contains($containerName); + }); + + // If all containers are excluded, don't update status + if ($relevantStatuses->isEmpty()) { + continue; + } + + // Aggregate status: if any container is running, app is running + $hasRunning = false; + $hasUnhealthy = false; + + foreach ($relevantStatuses as $status) { + if (str($status)->contains('running')) { + $hasRunning = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } + } + + $aggregatedStatus = null; + if ($hasRunning) { + $aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; + } else { + // All containers are exited + $aggregatedStatus = 'exited (unhealthy)'; + } + + // Update application status with aggregated result + if ($aggregatedStatus && $application->status !== $aggregatedStatus) { + $application->status = $aggregatedStatus; + $application->save(); + } + } + } + private function updateApplicationStatus(string $applicationId, string $containerStatus) { $application = $this->applications->where('id', $applicationId)->first(); if (! $application) { return; } - $application->status = $containerStatus; - $application->save(); + if ($application->status !== $containerStatus) { + $application->status = $containerStatus; + $application->save(); + } } - private function updateApplicationPreviewStatus(string $applicationId, string $containerStatus) + private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus) { - $application = $this->previews->where('id', $applicationId)->first(); + $application = $this->previews->where('application_id', $applicationId) + ->where('pull_request_id', $pullRequestId) + ->first(); if (! $application) { return; } - $application->status = $containerStatus; - $application->save(); + if ($application->status !== $containerStatus) { + $application->status = $containerStatus; + $application->save(); + } } private function updateNotFoundApplicationStatus() @@ -232,8 +328,21 @@ private function updateNotFoundApplicationStatus() $notFoundApplicationIds->each(function ($applicationId) { $application = Application::find($applicationId); if ($application) { - $application->status = 'exited'; - $application->save(); + // Don't mark as exited if already exited + if (str($application->status)->startsWith('exited')) { + return; + } + + // Only protection: Verify we received any container data at all + // If containers collection is completely empty, Sentinel might have failed + if ($this->containers->isEmpty()) { + return; + } + + if ($application->status !== 'exited') { + $application->status = 'exited'; + $application->save(); + } } }); } @@ -243,11 +352,36 @@ private function updateNotFoundApplicationPreviewStatus() { $notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds); if ($notFoundApplicationPreviewsIds->isNotEmpty()) { - $notFoundApplicationPreviewsIds->each(function ($applicationPreviewId) { - $applicationPreview = ApplicationPreview::find($applicationPreviewId); + $notFoundApplicationPreviewsIds->each(function ($previewKey) { + // Parse the previewKey format "application_id:pull_request_id" + $parts = explode(':', $previewKey); + if (count($parts) !== 2) { + return; + } + + $applicationId = $parts[0]; + $pullRequestId = $parts[1]; + + $applicationPreview = $this->previews->where('application_id', $applicationId) + ->where('pull_request_id', $pullRequestId) + ->first(); + if ($applicationPreview) { - $applicationPreview->status = 'exited'; - $applicationPreview->save(); + // Don't mark as exited if already exited + if (str($applicationPreview->status)->startsWith('exited')) { + return; + } + + // Only protection: Verify we received any container data at all + // If containers collection is completely empty, Sentinel might have failed + if ($this->containers->isEmpty()) { + + return; + } + if ($applicationPreview->status !== 'exited') { + $applicationPreview->status = 'exited'; + $applicationPreview->save(); + } } }); } @@ -260,7 +394,7 @@ private function updateProxyStatus() if ($this->foundProxy === false) { try { if (CheckProxy::run($this->server)) { - StartProxy::run($this->server, false); + StartProxy::run($this->server, async: false); $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); } } catch (\Throwable $e) { @@ -278,8 +412,10 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta if (! $database) { return; } - $database->status = $containerStatus; - $database->save(); + if ($database->status !== $containerStatus) { + $database->status = $containerStatus; + $database->save(); + } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running'; @@ -299,8 +435,10 @@ private function updateNotFoundDatabaseStatus() $notFoundDatabaseUuids->each(function ($databaseUuid) { $database = $this->databases->where('uuid', $databaseUuid)->first(); if ($database) { - $database->status = 'exited'; - $database->save(); + if ($database->status !== 'exited') { + $database->status = 'exited'; + $database->save(); + } if ($database->is_public) { StopDatabaseProxy::dispatch($database); } @@ -317,13 +455,20 @@ private function updateServiceSubStatus(string $serviceId, string $subType, stri } if ($subType === 'application') { $application = $service->applications()->where('id', $subId)->first(); - $application->status = $containerStatus; - $application->save(); + if ($application) { + if ($application->status !== $containerStatus) { + $application->status = $containerStatus; + $application->save(); + } + } } elseif ($subType === 'database') { $database = $service->databases()->where('id', $subId)->first(); - $database->status = $containerStatus; - $database->save(); - } else { + if ($database) { + if ($database->status !== $containerStatus) { + $database->status = $containerStatus; + $database->save(); + } + } } } @@ -335,8 +480,10 @@ private function updateNotFoundServiceStatus() $notFoundServiceApplicationIds->each(function ($serviceApplicationId) { $application = ServiceApplication::find($serviceApplicationId); if ($application) { - $application->status = 'exited'; - $application->save(); + if ($application->status !== 'exited') { + $application->status = 'exited'; + $application->save(); + } } }); } @@ -344,8 +491,10 @@ private function updateNotFoundServiceStatus() $notFoundServiceDatabaseIds->each(function ($serviceDatabaseId) { $database = ServiceDatabase::find($serviceDatabaseId); if ($database) { - $database->status = 'exited'; - $database->save(); + if ($database->status !== 'exited') { + $database->status = 'exited'; + $database->save(); + } } }); } diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index cf598c75c..c0284e1ee 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -45,7 +45,7 @@ public function handle() $query->cursor()->each(function ($certificate) use ($regenerated) { try { - $caCert = SslCertificate::where('server_id', $certificate->server_id) + $caCert = $certificate->server->sslCertificates() ->where('is_ca_certificate', true) ->first(); diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index 7fc716f70..dba4f4ac8 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -2,7 +2,6 @@ namespace App\Jobs; -use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StopProxy; use App\Models\Server; @@ -24,7 +23,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public function middleware(): array { - return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; + return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; } public function __construct(public Server $server) {} @@ -36,9 +35,9 @@ public function handle() $this->server->proxy->force_stop = false; $this->server->save(); + StartProxy::run($this->server, force: true); - CheckProxy::run($this->server, true); } catch (\Throwable $e) { return handleError($e); } diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php new file mode 100644 index 000000000..18ca0008c --- /dev/null +++ b/app/Jobs/ScheduledJobManager.php @@ -0,0 +1,313 @@ +onQueue($this->determineQueue()); + } + + private function determineQueue(): string + { + $preferredQueue = 'crons'; + $fallbackQueue = 'high'; + + $configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default')); + + return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue; + } + + /** + * Get the middleware the job should pass through. + */ + public function middleware(): array + { + return [ + (new WithoutOverlapping('scheduled-job-manager')) + ->releaseAfter(60), // Release the lock after 60 seconds if job fails + ]; + } + + public function handle(): void + { + // Freeze the execution time at the start of the job + $this->executionTime = Carbon::now(); + + // Process backups - don't let failures stop task processing + try { + $this->processScheduledBackups(); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + + // Process tasks - don't let failures stop the job manager + try { + $this->processScheduledTasks(); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + + // Process Docker cleanups - don't let failures stop the job manager + try { + $this->processDockerCleanups(); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to process docker cleanups', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + private function processScheduledBackups(): void + { + $backups = ScheduledDatabaseBackup::with(['database']) + ->where('enabled', true) + ->get(); + + foreach ($backups as $backup) { + try { + // Apply the same filtering logic as the original + if (! $this->shouldProcessBackup($backup)) { + continue; + } + + $server = $backup->server(); + $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + + $frequency = $backup->frequency; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + if ($this->shouldRunNow($frequency, $serverTimezone)) { + DatabaseBackupJob::dispatch($backup); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing backup', [ + 'backup_id' => $backup->id, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function processScheduledTasks(): void + { + $tasks = ScheduledTask::with(['service', 'application']) + ->where('enabled', true) + ->get(); + + foreach ($tasks as $task) { + try { + if (! $this->shouldProcessTask($task)) { + continue; + } + + $server = $task->server(); + $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + + $frequency = $task->frequency; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + if ($this->shouldRunNow($frequency, $serverTimezone)) { + ScheduledTaskJob::dispatch($task); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing task', [ + 'task_id' => $task->id, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool + { + if (blank(data_get($backup, 'database'))) { + $backup->delete(); + + return false; + } + + $server = $backup->server(); + if (blank($server)) { + $backup->delete(); + + return false; + } + + if ($server->isFunctional() === false) { + return false; + } + + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + return false; + } + + return true; + } + + private function shouldProcessTask(ScheduledTask $task): bool + { + $service = $task->service; + $application = $task->application; + + $server = $task->server(); + if (blank($server)) { + $task->delete(); + + return false; + } + + if ($server->isFunctional() === false) { + return false; + } + + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + return false; + } + + if (! $service && ! $application) { + $task->delete(); + + return false; + } + + if ($application && str($application->status)->contains('running') === false) { + return false; + } + + if ($service && str($service->status)->contains('running') === false) { + return false; + } + + return true; + } + + private function shouldRunNow(string $frequency, string $timezone): bool + { + $cron = new CronExpression($frequency); + + // Use the frozen execution time, not the current time + // Fallback to current time if execution time is not set (shouldn't happen) + $baseTime = $this->executionTime ?? Carbon::now(); + $executionTime = $baseTime->copy()->setTimezone($timezone); + + return $cron->isDue($executionTime); + } + + private function processDockerCleanups(): void + { + // Get all servers that need cleanup checks + $servers = $this->getServersForCleanup(); + + foreach ($servers as $server) { + try { + if (! $this->shouldProcessDockerCleanup($server)) { + continue; + } + + $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + + $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + // Use the frozen execution time for consistent evaluation + if ($this->shouldRunNow($frequency, $serverTimezone)) { + DockerCleanupJob::dispatch( + $server, + false, + $server->settings->delete_unused_volumes, + $server->settings->delete_unused_networks + ); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function getServersForCleanup(): Collection + { + $query = Server::with('settings') + ->where('ip', '!=', '1.2.3.4'); + + if (isCloud()) { + $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); + $own = Team::find(0)->servers()->with('settings')->get(); + + return $servers->merge($own); + } + + return $query->get(); + } + + private function shouldProcessDockerCleanup(Server $server): bool + { + if (! $server->isFunctional()) { + return false; + } + + // In cloud, check subscription status (except team 0) + if (isCloud() && $server->team_id !== 0) { + if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) { + return false; + } + } + + return true; + } +} diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 6c0c017e7..609595356 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Events\ScheduledTaskDone; +use App\Exceptions\NonReportableException; use App\Models\Application; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; @@ -120,7 +121,7 @@ public function handle(): void } // No valid container was found. - throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); + throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); } catch (\Throwable $e) { if ($this->task_log) { $this->task_log->update([ diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php new file mode 100644 index 000000000..607fda3fe --- /dev/null +++ b/app/Jobs/SendWebhookJob.php @@ -0,0 +1,60 @@ +onQueue('high'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + if (isDev()) { + ray('Sending webhook notification', [ + 'url' => $this->webhookUrl, + 'payload' => $this->payload, + ]); + } + + $response = Http::post($this->webhookUrl, $this->payload); + + if (isDev()) { + ray('Webhook response', [ + 'status' => $response->status(), + 'body' => $response->body(), + 'successful' => $response->successful(), + ]); + } + } +} diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 9818d5c6a..499035237 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -28,7 +28,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue public function middleware(): array { - return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; + return [(new WithoutOverlapping('server-check-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; } public function __construct(public Server $server) {} @@ -68,7 +68,7 @@ public function handle() try { $shouldStart = CheckProxy::run($this->server); if ($shouldStart) { - StartProxy::run($this->server, false); + StartProxy::run($this->server, async: false); $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); } } catch (\Throwable $e) { diff --git a/app/Jobs/ServerCheckNewJob.php b/app/Jobs/ServerCheckNewJob.php deleted file mode 100644 index 3e8e60a31..000000000 --- a/app/Jobs/ServerCheckNewJob.php +++ /dev/null @@ -1,34 +0,0 @@ -server); - ResourcesCheck::dispatch($this->server); - } catch (\Throwable $e) { - return handleError($e); - } - } -} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php new file mode 100644 index 000000000..9dbce4bfe --- /dev/null +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -0,0 +1,187 @@ +server->uuid))->expireAfter(45)->dontRelease()]; + } + + private function disableSshMux(): void + { + $configRepository = app(ConfigurationRepository::class); + $configRepository->disableSshMux(); + } + + public function handle() + { + try { + // Check if server is disabled + if ($this->server->settings->force_disabled) { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + Log::debug('ServerConnectionCheck: Server is disabled', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + return; + } + + // Check Hetzner server status if applicable + if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) { + $this->checkHetznerStatus(); + } + + // Temporarily disable mux if requested + if ($this->disableMux) { + $this->disableSshMux(); + } + + // Check basic connectivity first + $isReachable = $this->checkConnection(); + + if (! $isReachable) { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + Log::warning('ServerConnectionCheck: Server not reachable', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + 'server_ip' => $this->server->ip, + ]); + + return; + } + + // Server is reachable, check if Docker is available + $isUsable = $this->checkDockerAvailability(); + + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => $isUsable, + ]); + + } catch (\Throwable $e) { + + Log::error('ServerConnectionCheckJob failed', [ + 'error' => $e->getMessage(), + 'server_id' => $this->server->id, + ]); + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + throw $e; + } + } + + private function checkHetznerStatus(): void + { + try { + $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $serverData = $hetznerService->getServer($this->server->hetzner_server_id); + $status = $serverData['status'] ?? null; + + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: Hetzner status check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + } + if ($this->server->hetzner_server_status !== $status) { + $this->server->update(['hetzner_server_status' => $status]); + $this->server->hetzner_server_status = $status; + if ($status === 'off') { + ray('Server is powered off, marking as unreachable'); + throw new \Exception('Server is powered off'); + } + } + + } + + private function checkConnection(): bool + { + try { + // Use instant_remote_process with a simple command + // This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc. + $output = instant_remote_process_with_timeout( + ['ls -la /'], + $this->server, + false // don't throw error + ); + + return $output !== null; + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: Connection check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + private function checkDockerAvailability(): bool + { + try { + // Use instant_remote_process to check Docker + // The function will automatically handle sudo for non-root users + $output = instant_remote_process_with_timeout( + ['docker version --format json'], + $this->server, + false // don't throw error + ); + + if ($output === null) { + return false; + } + + // Try to parse the JSON output to ensure Docker is really working + $output = trim($output); + if (! empty($output)) { + $dockerInfo = json_decode($output, true); + + return isset($dockerInfo['Server']['Version']); + } + + return false; + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: Docker check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php new file mode 100644 index 000000000..043845c00 --- /dev/null +++ b/app/Jobs/ServerManagerJob.php @@ -0,0 +1,170 @@ +onQueue('high'); + } + + public function handle(): void + { + // Freeze the execution time at the start of the job + $this->executionTime = Carbon::now(); + if (isCloud()) { + $this->checkFrequency = '*/5 * * * *'; + } + $this->settings = instanceSettings(); + $this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone'); + + if (validate_timezone($this->instanceTimezone) === false) { + $this->instanceTimezone = config('app.timezone'); + } + + // Get all servers to process + $servers = $this->getServers(); + + // Dispatch ServerConnectionCheck for all servers efficiently + $this->dispatchConnectionChecks($servers); + + // Process server-specific scheduled tasks + $this->processScheduledTasks($servers); + } + + private function getServers(): Collection + { + $allServers = Server::where('ip', '!=', '1.2.3.4'); + + if (isCloud()) { + $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); + $own = Team::find(0)->servers; + + return $servers->merge($own); + } else { + return $allServers->get(); + } + } + + private function dispatchConnectionChecks(Collection $servers): void + { + + if ($this->shouldRunNow($this->checkFrequency)) { + $servers->each(function (Server $server) { + try { + ServerConnectionCheckJob::dispatch($server); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); + } + }); + } + } + + private function processScheduledTasks(Collection $servers): void + { + foreach ($servers as $server) { + try { + $this->processServerTasks($server); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing server tasks', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function processServerTasks(Server $server): void + { + // Check if we should run sentinel-based checks + $lastSentinelUpdate = $server->sentinel_updated_at; + $waitTime = $server->waitBeforeDoingSshCheck(); + $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime)); + + if ($sentinelOutOfSync) { + // Dispatch jobs if Sentinel is out of sync + if ($this->shouldRunNow($this->checkFrequency)) { + ServerCheckJob::dispatch($server); + } + + // Dispatch ServerStorageCheckJob if due + $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *'); + if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { + $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; + } + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency); + + if ($shouldRunStorageCheck) { + ServerStorageCheckJob::dispatch($server); + } + } + + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + + // Dispatch ServerPatchCheckJob if due (weekly) + $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); + + if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight + ServerPatchCheckJob::dispatch($server); + } + + // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) + $isSentinelEnabled = $server->isSentinelEnabled(); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + + if ($shouldRestartSentinel) { + dispatch(function () use ($server) { + $server->restartContainer('coolify-sentinel'); + }); + } + } + + private function shouldRunNow(string $frequency, ?string $timezone = null): bool + { + $cron = new CronExpression($frequency); + + // Use the frozen execution time, not the current time + $baseTime = $this->executionTime ?? Carbon::now(); + $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); + + return $cron->isDue($executionTime); + } +} diff --git a/app/Jobs/ServerPatchCheckJob.php b/app/Jobs/ServerPatchCheckJob.php new file mode 100644 index 000000000..18999c009 --- /dev/null +++ b/app/Jobs/ServerPatchCheckJob.php @@ -0,0 +1,68 @@ +server->uuid))->expireAfter(600)->dontRelease()]; + } + + public function __construct(public Server $server) {} + + public function handle(): void + { + try { + if ($this->server->serverStatus() === false) { + return; + } + + $team = data_get($this->server, 'team'); + if (! $team) { + return; + } + + // Check for updates + $patchData = CheckUpdates::run($this->server); + + if (isset($patchData['error'])) { + $team->notify(new ServerPatchCheck($this->server, $patchData)); + + return; // Skip if there's an error checking for updates + } + + $totalUpdates = $patchData['total_updates'] ?? 0; + + // Only send notification if there are updates available + if ($totalUpdates > 0) { + $team->notify(new ServerPatchCheck($this->server, $patchData)); + } + } catch (\Throwable $e) { + // Log error but don't fail the job + \Illuminate\Support\Facades\Log::error('ServerPatchCheckJob failed: '.$e->getMessage(), [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } +} diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 9a8d86be1..9d45491c6 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -11,8 +11,9 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\RateLimiter; +use Laravel\Horizon\Contracts\Silenced; -class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue +class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f1c5bc1a8..aebceaa6d 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -58,7 +58,7 @@ public function handle(): void case 'checkout.session.completed': $clientReferenceId = data_get($data, 'client_reference_id'); if (is_null($clientReferenceId)) { - send_internal_notification('Checkout session completed without client reference id.'); + // send_internal_notification('Checkout session completed without client reference id.'); break; } $userId = Str::before($clientReferenceId, ':'); @@ -68,7 +68,7 @@ public function handle(): void $team = Team::find($teamId); $found = $team->members->where('id', $userId)->first(); if (! $found->isAdmin()) { - send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); + // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } $subscription = Subscription::where('team_id', $teamId)->first(); @@ -93,33 +93,101 @@ public function handle(): void break; case 'invoice.paid': $customerId = data_get($data, 'customer'); + $invoiceAmount = data_get($data, 'amount_paid', 0); + $subscriptionId = data_get($data, 'subscription'); $planId = data_get($data, 'lines.data.0.plan.id'); if (Str::contains($excludedPlans, $planId)) { - send_internal_notification('Subscription excluded.'); + // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); - if ($subscription) { - $subscription->update([ - 'stripe_invoice_paid' => true, - 'stripe_past_due' => false, - ]); - } else { + if (! $subscription) { throw new \RuntimeException("No subscription found for customer: {$customerId}"); } + + if ($subscription->stripe_subscription_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + switch ($stripeSubscription->status) { + case 'active': + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + break; + + case 'past_due': + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => true, + ]); + break; + + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + send_internal_notification( + "Invoice paid for {$stripeSubscription->status} subscription. ". + "Customer: {$customerId}, Amount: \${$invoiceAmount}" + ); + break; + + default: + VerifyStripeSubscriptionStatusJob::dispatch($subscription) + ->delay(now()->addSeconds(20)); + break; + } + } catch (\Exception $e) { + VerifyStripeSubscriptionStatusJob::dispatch($subscription) + ->delay(now()->addSeconds(20)); + + send_internal_notification( + 'Failed to verify subscription status in invoice.paid: '.$e->getMessage() + ); + } + } else { + VerifyStripeSubscriptionStatusJob::dispatch($subscription) + ->delay(now()->addSeconds(20)); + } break; case 'invoice.payment_failed': $customerId = data_get($data, 'customer'); + $invoiceId = data_get($data, 'id'); + $paymentIntentId = data_get($data, 'payment_intent'); + $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { - send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found for customer: {$customerId}"); } $team = data_get($subscription, 'team'); if (! $team) { - send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } + + // Verify payment status with Stripe API before sending failure notification + if ($paymentIntentId) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); + + if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { + break; + } + + if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) { + SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60)); + break; + } + } catch (\Exception $e) { + } + } + if (! $subscription->stripe_invoice_paid) { SubscriptionInvoiceFailedJob::dispatch($team); // send_internal_notification('Invoice payment failed: '.$customerId); @@ -129,11 +197,11 @@ public function handle(): void $customerId = data_get($data, 'customer'); $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { - send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); } if ($subscription->stripe_invoice_paid) { - send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); + // send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); return; } @@ -154,7 +222,7 @@ public function handle(): void $team = Team::find($teamId); $found = $team->members->where('id', $userId)->first(); if (! $found->isAdmin()) { - send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); + // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } $subscription = Subscription::where('team_id', $teamId)->first(); @@ -177,7 +245,7 @@ public function handle(): void $subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id'); $planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id'); if (Str::contains($excludedPlans, $planId)) { - send_internal_notification('Subscription excluded.'); + // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); @@ -194,7 +262,7 @@ public function handle(): void 'stripe_invoice_paid' => false, ]); } else { - send_internal_notification('No subscription and team id found'); + // send_internal_notification('No subscription and team id found'); throw new \RuntimeException('No subscription and team id found'); } } @@ -230,7 +298,7 @@ public function handle(): void $subscription->update([ 'stripe_past_due' => true, ]); - send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); + // send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); } } if ($status === 'unpaid') { @@ -238,13 +306,13 @@ public function handle(): void $subscription->update([ 'stripe_invoice_paid' => false, ]); - send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); + // send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); } $team = data_get($subscription, 'team'); if ($team) { $team->subscriptionEnded(); } else { - send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } } @@ -273,11 +341,11 @@ public function handle(): void if ($team) { $team->subscriptionEnded(); } else { - send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } } else { - send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); } break; diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php index dc511f445..927d50467 100755 --- a/app/Jobs/SubscriptionInvoiceFailedJob.php +++ b/app/Jobs/SubscriptionInvoiceFailedJob.php @@ -23,6 +23,47 @@ public function __construct(protected Team $team) public function handle() { try { + // Double-check subscription status before sending failure notification + $subscription = $this->team->subscription; + if ($subscription && $subscription->stripe_customer_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + + if ($subscription->stripe_subscription_id) { + $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + + if (in_array($stripeSubscription->status, ['active', 'trialing'])) { + if (! $subscription->stripe_invoice_paid) { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + } + + return; + } + } + + $invoices = $stripe->invoices->all([ + 'customer' => $subscription->stripe_customer_id, + 'limit' => 3, + ]); + + foreach ($invoices->data as $invoice) { + if ($invoice->paid && $invoice->created > (time() - 3600)) { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + + return; + } + } + } catch (\Exception $e) { + } + } + + // If we reach here, payment genuinely failed $session = getStripeCustomerPortalSession($this->team); $mail = new MailMessage; $mail->view('emails.subscription-invoice-failed', [ diff --git a/app/Jobs/UpdateStripeCustomerEmailJob.php b/app/Jobs/UpdateStripeCustomerEmailJob.php new file mode 100644 index 000000000..2e86c14a0 --- /dev/null +++ b/app/Jobs/UpdateStripeCustomerEmailJob.php @@ -0,0 +1,133 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + if (! isCloud() || ! $this->team->subscription) { + Log::info('Skipping Stripe email update - not cloud or no subscription', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + // Check if the user changing email is a team owner + $isOwner = $this->team->members() + ->wherePivot('role', 'owner') + ->where('users.id', $this->userId) + ->exists(); + + if (! $isOwner) { + Log::info('Skipping Stripe email update - user is not team owner', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + // Get current Stripe customer email to verify it matches the user's old email + $stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id'); + if (! $stripe_customer_id) { + Log::info('Skipping Stripe email update - no Stripe customer ID', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + Stripe::setApiKey(config('subscription.stripe_api_key')); + + try { + $stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id); + $currentStripeEmail = $stripeCustomer->email; + + // Only update if the current Stripe email matches the user's old email + if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) { + Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'stripe_email' => $currentStripeEmail, + 'user_old_email' => $this->oldEmail, + ]); + + return; + } + + // Update Stripe customer email + \Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]); + + } catch (\Exception $e) { + Log::error('Failed to retrieve or update Stripe customer', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'stripe_customer_id' => $stripe_customer_id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + + Log::info('Successfully updated Stripe customer email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + ]); + } catch (\Exception $e) { + Log::error('Failed to update Stripe customer email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + 'error' => $e->getMessage(), + 'attempt' => $this->attempts(), + ]); + + // Re-throw to trigger retry + throw $e; + } + } + + public function failed(\Throwable $exception): void + { + Log::error('Permanently failed to update Stripe customer email after all retries', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php new file mode 100644 index 000000000..388791f10 --- /dev/null +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -0,0 +1,162 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + // Mark validation as in progress + $this->server->update(['is_validating' => true]); + + Log::info('ValidateAndInstallServer: Starting validation', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + 'attempt' => $this->numberOfTries + 1, + ]); + + // Validate connection + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if (! $uptime) { + $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: Server not reachable', [ + 'server_id' => $this->server->id, + 'error' => $error, + ]); + + return; + } + + // Validate OS + $supportedOsType = $this->server->validateOS(); + if (! $supportedOsType) { + $errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: documentation.'; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: OS not supported', [ + 'server_id' => $this->server->id, + ]); + + return; + } + + // Check if Docker is installed + $dockerInstalled = $this->server->validateDockerEngine(); + $dockerComposeInstalled = $this->server->validateDockerCompose(); + + if (! $dockerInstalled || ! $dockerComposeInstalled) { + // Try to install Docker + if ($this->numberOfTries >= $this->maxTries) { + $errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: documentation.'; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [ + 'server_id' => $this->server->id, + 'attempts' => $this->numberOfTries, + ]); + + return; + } + + Log::info('ValidateAndInstallServer: Installing Docker', [ + 'server_id' => $this->server->id, + 'attempt' => $this->numberOfTries + 1, + ]); + + // Install Docker + $this->server->installDocker(); + + // Retry validation after installation + self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30)); + + return; + } + + // Validate Docker version + $dockerVersion = $this->server->validateDockerEngineVersion(); + if (! $dockerVersion) { + $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); + $errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; + $this->server->update([ + 'validation_logs' => $errorMessage, + 'is_validating' => false, + ]); + Log::error('ValidateAndInstallServer: Docker version not sufficient', [ + 'server_id' => $this->server->id, + ]); + + return; + } + + // Validation successful! + Log::info('ValidateAndInstallServer: Validation successful', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + // Start proxy if needed + if (! $this->server->isBuildServer()) { + $proxyShouldRun = CheckProxy::run($this->server, true); + if ($proxyShouldRun) { + StartProxy::dispatch($this->server); + } + } + + // Mark validation as complete + $this->server->update(['is_validating' => false]); + + // Refresh server to get latest state + $this->server->refresh(); + + // Broadcast events to update UI + ServerValidated::dispatch($this->server->team_id, $this->server->uuid); + ServerReachabilityChanged::dispatch($this->server); + + } catch (\Throwable $e) { + Log::error('ValidateAndInstallServer: Exception occurred', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->server->update([ + 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(), + 'is_validating' => false, + ]); + } + } +} diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php new file mode 100644 index 000000000..58b6944a2 --- /dev/null +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -0,0 +1,106 @@ +onQueue('high'); + } + + public function handle(): void + { + // If no subscription ID yet, try to find it via customer + if (! $this->subscription->stripe_subscription_id && + $this->subscription->stripe_customer_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $subscriptions = $stripe->subscriptions->all([ + 'customer' => $this->subscription->stripe_customer_id, + 'limit' => 1, + ]); + + if ($subscriptions->data) { + $this->subscription->update([ + 'stripe_subscription_id' => $subscriptions->data[0]->id, + ]); + } + } catch (\Exception $e) { + // Continue without subscription ID + } + } + + if (! $this->subscription->stripe_subscription_id) { + return; + } + + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripeSubscription = $stripe->subscriptions->retrieve( + $this->subscription->stripe_subscription_id + ); + + switch ($stripeSubscription->status) { + case 'active': + $this->subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + 'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end, + ]); + break; + + case 'past_due': + // Keep subscription active but mark as past_due + $this->subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => true, + 'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end, + ]); + break; + + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + // Ensure subscription is marked as inactive + $this->subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + + // Trigger subscription ended logic if canceled + if ($stripeSubscription->status === 'canceled') { + $team = $this->subscription->team; + if ($team) { + $team->subscriptionEnded(); + } + } + break; + + default: + send_internal_notification( + 'Unknown subscription status in VerifyStripeSubscriptionStatusJob: '.$stripeSubscription->status. + ' for customer: '.$this->subscription->stripe_customer_id + ); + break; + } + } catch (\Exception $e) { + send_internal_notification( + 'VerifyStripeSubscriptionStatusJob failed for subscription ID '.$this->subscription->id.': '.$e->getMessage() + ); + } + } +} diff --git a/app/Listeners/CloudflareTunnelChangedNotification.php b/app/Listeners/CloudflareTunnelChangedNotification.php new file mode 100644 index 000000000..34e713f95 --- /dev/null +++ b/app/Listeners/CloudflareTunnelChangedNotification.php @@ -0,0 +1,66 @@ +server = Server::where('id', $server_id)->firstOrFail(); + + // Check if cloudflare tunnel is running (container is healthy) - try 3 times with 5 second intervals + $cloudflareHealthy = false; + $attempts = 3; + + for ($i = 1; $i <= $attempts; $i++) { + \Log::debug("Cloudflare health check attempt {$i}/{$attempts}", ['server_id' => $server_id]); + $result = instant_remote_process_with_timeout(['docker inspect coolify-cloudflared | jq -e ".[0].State.Health.Status == \"healthy\""'], $this->server, false, 10); + + if (blank($result)) { + \Log::debug("Cloudflare Tunnels container not found on attempt {$i}", ['server_id' => $server_id]); + } elseif ($result === 'true') { + \Log::debug("Cloudflare Tunnels container healthy on attempt {$i}", ['server_id' => $server_id]); + $cloudflareHealthy = true; + break; + } else { + \Log::debug("Cloudflare Tunnels container not healthy on attempt {$i}", ['server_id' => $server_id, 'result' => $result]); + } + + // Sleep between attempts (except after the last attempt) + if ($i < $attempts) { + Sleep::for(5)->seconds(); + } + } + + if (! $cloudflareHealthy) { + \Log::error('Cloudflare Tunnels container failed all health checks.', ['server_id' => $server_id, 'attempts' => $attempts]); + + return; + } + $this->server->settings->update([ + 'is_cloudflare_tunnel' => true, + ]); + + // Only update IP if it's not already set to the ssh_domain or if it's empty + if ($this->server->ip !== $ssh_domain && ! empty($ssh_domain)) { + \Log::debug('Cloudflare Tunnels configuration updated - updating IP address.', ['old_ip' => $this->server->ip, 'new_ip' => $ssh_domain]); + $this->server->update(['ip' => $ssh_domain]); + } else { + \Log::debug('Cloudflare Tunnels configuration updated - IP address unchanged.', ['current_ip' => $this->server->ip]); + } + $teamId = $this->server->team_id; + CloudflareTunnelConfigured::dispatch($teamId); + } +} diff --git a/app/Listeners/ProxyStartedNotification.php b/app/Listeners/ProxyStartedNotification.php deleted file mode 100644 index 9045b1e5c..000000000 --- a/app/Listeners/ProxyStartedNotification.php +++ /dev/null @@ -1,22 +0,0 @@ -server = data_get($event, 'data'); - $this->server->setupDefaultRedirect(); - $this->server->setupDynamicProxyConfiguration(); - $this->server->proxy->force_stop = false; - $this->server->save(); - } -} diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php new file mode 100644 index 000000000..7b23724e2 --- /dev/null +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -0,0 +1,42 @@ +data; + if (is_null($serverId)) { + return; + } + $server = Server::where('id', $serverId)->first(); + if (is_null($server)) { + return; + } + $proxyContainerName = 'coolify-proxy'; + $status = getContainerStatus($server, $proxyContainerName); + $server->proxy->set('status', $status); + $server->save(); + + ProxyStatusChangedUI::dispatch($server->team_id); + if ($status === 'running') { + $server->setupDefaultRedirect(); + $server->setupDynamicProxyConfiguration(); + $server->proxy->force_stop = false; + $server->save(); + } + if ($status === 'created') { + instant_remote_process([ + 'docker rm -f coolify-proxy', + ], $server); + } + } +} diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 3bb76cc1f..54034ef7a 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -14,22 +14,25 @@ class ActivityMonitor extends Component public $eventToDispatch = 'activityFinished'; + public $eventData = null; + public $isPollingActive = false; public bool $fullHeight = false; - public bool $showWaiting = false; + public $activity; - protected $activity; + public bool $showWaiting = true; public static $eventDispatched = false; protected $listeners = ['activityMonitor' => 'newMonitorActivity']; - public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished') + public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null) { $this->activityId = $activityId; $this->eventToDispatch = $eventToDispatch; + $this->eventData = $eventData; $this->hydrateActivity(); @@ -55,7 +58,11 @@ public function polling() if ($user) { $teamId = $user->currentTeam()->id; if (! self::$eventDispatched) { - $this->eventToDispatch::dispatch($teamId); + if (filled($this->eventData)) { + $this->eventToDispatch::dispatch($teamId, $this->eventData); + } else { + $this->eventToDispatch::dispatch($teamId); + } self::$eventDispatched = true; } } @@ -63,7 +70,11 @@ public function polling() return; } if (! self::$eventDispatched) { - $this->dispatch($this->eventToDispatch); + if (filled($this->eventData)) { + $this->dispatch($this->eventToDispatch, $this->eventData); + } else { + $this->dispatch($this->eventToDispatch); + } self::$eventDispatched = true; } } diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 430470fa0..ac2b9213b 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -16,14 +16,18 @@ class Index extends Component { protected $listeners = ['refreshBoardingIndex' => 'validateServer']; + #[\Livewire\Attributes\Url(as: 'step', history: true)] public string $currentState = 'welcome'; + #[\Livewire\Attributes\Url(keep: true)] public ?string $selectedServerType = null; public ?Collection $privateKeys = null; + #[\Livewire\Attributes\Url(keep: true)] public ?int $selectedExistingPrivateKey = null; + #[\Livewire\Attributes\Url(keep: true)] public ?string $privateKeyType = null; public ?string $privateKey = null; @@ -38,6 +42,7 @@ class Index extends Component public ?Collection $servers = null; + #[\Livewire\Attributes\Url(keep: true)] public ?int $selectedExistingServer = null; public ?string $remoteServerName = null; @@ -58,6 +63,7 @@ class Index extends Component public Collection $projects; + #[\Livewire\Attributes\Url(keep: true)] public ?int $selectedProject = null; public ?Project $createdProject = null; @@ -79,17 +85,68 @@ public function mount() $this->minDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); $this->privateKeyName = generate_random_name(); $this->remoteServerName = generate_random_name(); - if (isDev()) { - $this->privateKey = '-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY-----'; - $this->privateKeyDescription = 'Created by Coolify'; - $this->remoteServerDescription = 'Created by Coolify'; - $this->remoteServerHost = 'coolify-testing-host'; + + // Initialize collections to avoid null errors + if ($this->privateKeys === null) { + $this->privateKeys = collect(); + } + if ($this->servers === null) { + $this->servers = collect(); + } + if (! isset($this->projects)) { + $this->projects = collect(); + } + + // Restore state when coming from URL with query params + if ($this->selectedServerType === 'localhost' && $this->selectedExistingServer === 0) { + $this->createdServer = Server::find(0); + if ($this->createdServer) { + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); + } + } + + if ($this->selectedServerType === 'remote') { + if ($this->privateKeys->isEmpty()) { + $this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); + } + if ($this->servers->isEmpty()) { + $this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); + } + + if ($this->selectedExistingServer) { + $this->createdServer = Server::find($this->selectedExistingServer); + if ($this->createdServer) { + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); + $this->updateServerDetails(); + } + } + + if ($this->selectedExistingPrivateKey) { + $this->createdPrivateKey = PrivateKey::where('team_id', currentTeam()->id) + ->where('id', $this->selectedExistingPrivateKey) + ->first(); + if ($this->createdPrivateKey) { + $this->privateKey = $this->createdPrivateKey->private_key; + $this->publicKey = $this->createdPrivateKey->getPublicKey(); + } + } + + // Auto-regenerate key pair for "Generate with Coolify" mode on page refresh + if ($this->privateKeyType === 'create' && empty($this->privateKey)) { + $this->createNewPrivateKey(); + } + } + + if ($this->selectedProject) { + $this->createdProject = Project::find($this->selectedProject); + if (! $this->createdProject) { + $this->projects = Project::ownedByCurrentTeam(['name'])->get(); + } + } + + // Load projects when on create-project state (for page refresh) + if ($this->currentState === 'create-project' && $this->projects->isEmpty()) { + $this->projects = Project::ownedByCurrentTeam(['name'])->get(); } } @@ -129,41 +186,16 @@ public function setServerType(string $type) return $this->validateServer('localhost'); } elseif ($this->selectedServerType === 'remote') { - if (isDev()) { - $this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->get(); - } else { - $this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); - } + $this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); + // Auto-select first key if available for better UX if ($this->privateKeys->count() > 0) { $this->selectedExistingPrivateKey = $this->privateKeys->first()->id; } - $this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); - if ($this->servers->count() > 0) { - $this->selectedExistingServer = $this->servers->first()->id; - $this->updateServerDetails(); - $this->currentState = 'select-existing-server'; - - return; - } + // Onboarding always creates new servers, skip existing server selection $this->currentState = 'private-key'; } } - public function selectExistingServer() - { - $this->createdServer = Server::find($this->selectedExistingServer); - if (! $this->createdServer) { - $this->dispatch('error', 'Server is not found.'); - $this->currentState = 'private-key'; - - return; - } - $this->selectedExistingPrivateKey = $this->createdServer->privateKey->id; - $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); - $this->updateServerDetails(); - $this->currentState = 'validate-server'; - } - private function updateServerDetails() { if ($this->createdServer) { @@ -181,7 +213,7 @@ public function getProxyType() public function selectExistingPrivateKey() { if (is_null($this->selectedExistingPrivateKey)) { - $this->restartBoarding(); + $this->dispatch('error', 'Please select a private key.'); return; } @@ -202,6 +234,9 @@ public function setPrivateKey(string $type) $this->privateKeyType = $type; if ($type === 'create') { $this->createNewPrivateKey(); + } else { + $this->privateKey = null; + $this->publicKey = null; } $this->currentState = 'create-private-key'; } diff --git a/app/Livewire/Concerns/SynchronizesModelData.php b/app/Livewire/Concerns/SynchronizesModelData.php new file mode 100644 index 000000000..f8218c715 --- /dev/null +++ b/app/Livewire/Concerns/SynchronizesModelData.php @@ -0,0 +1,35 @@ + Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content']) + */ + abstract protected function getModelBindings(): array; + + /** + * Synchronize component properties TO the model. + * Copies values from component properties to the model. + */ + protected function syncToModel(): void + { + foreach ($this->getModelBindings() as $property => $modelKey) { + data_set($this, $modelKey, $this->{$property}); + } + } + + /** + * Synchronize component properties FROM the model. + * Copies values from the model to component properties. + */ + protected function syncFromModel(): void + { + foreach ($this->getModelBindings() as $property => $modelKey) { + $this->{$property} = data_get($this, $modelKey); + } + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index edbdd25fe..57ecaa8a2 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,56 +2,25 @@ namespace App\Livewire; -use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Artisan; use Livewire\Component; class Dashboard extends Component { - public $projects = []; + public Collection $projects; public Collection $servers; public Collection $privateKeys; - public array $deploymentsPerServer = []; - public function mount() { $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); $this->servers = Server::ownedByCurrentTeam()->get(); $this->projects = Project::ownedByCurrentTeam()->get(); - $this->loadDeployments(); - } - - public function cleanupQueue() - { - Artisan::queue('cleanup:deployment-queue', [ - '--team-id' => currentTeam()->id, - ]); - } - - public function loadDeployments() - { - $this->deploymentsPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $this->servers->pluck('id'))->get([ - 'id', - 'application_id', - 'application_name', - 'deployment_url', - 'pull_request_id', - 'server_name', - 'server_id', - 'status', - ])->sortBy('id')->groupBy('server_name')->toArray(); - } - - public function navigateToProject($projectUuid) - { - return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false); } public function render() diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php new file mode 100644 index 000000000..ac9cfd1c2 --- /dev/null +++ b/app/Livewire/DeploymentsIndicator.php @@ -0,0 +1,50 @@ +get(); + + return ApplicationDeploymentQueue::with(['application.environment.project']) + ->whereIn('status', ['in_progress', 'queued']) + ->whereIn('server_id', $servers->pluck('id')) + ->orderBy('id') + ->get([ + 'id', + 'application_id', + 'application_name', + 'deployment_url', + 'pull_request_id', + 'server_name', + 'server_id', + 'status', + ]); + } + + #[Computed] + public function deploymentCount() + { + return $this->deployments->count(); + } + + public function toggleExpanded() + { + $this->expanded = ! $this->expanded; + } + + public function render() + { + return view('livewire.deployments-indicator'); + } +} diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index eb768d191..819ac3ecd 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -5,6 +5,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -12,6 +13,8 @@ class Docker extends Component { + use AuthorizesRequests; + #[Locked] public $servers; @@ -67,6 +70,7 @@ public function generateName() public function submit() { try { + $this->authorize('create', StandaloneDocker::class); $this->validate(); if ($this->isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 5c4d6c170..98cf72376 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -5,12 +5,15 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + #[Locked] public $destination; @@ -63,6 +66,8 @@ public function syncData(bool $toModel = false) public function submit() { try { + $this->authorize('update', $this->destination); + $this->syncData(true); $this->dispatch('success', 'Destination saved.'); } catch (\Throwable $e) { @@ -73,6 +78,8 @@ public function submit() public function delete() { try { + $this->authorize('delete', $this->destination); + if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php new file mode 100644 index 000000000..e1dd678ff --- /dev/null +++ b/app/Livewire/GlobalSearch.php @@ -0,0 +1,1465 @@ +searchQuery = ''; + $this->isModalOpen = false; + $this->searchResults = []; + $this->allSearchableItems = []; + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->isSelectingResource = false; + } + + public function openSearchModal() + { + $this->isModalOpen = true; + $this->loadSearchableItems(); + $this->loadCreatableItems(); + $this->dispatch('search-modal-opened'); + } + + public function closeSearchModal() + { + $this->isModalOpen = false; + $this->searchQuery = ''; + $this->previousTrimmedQuery = ''; + $this->searchResults = []; + } + + public static function getCacheKey($teamId) + { + return 'global_search_items_'.$teamId; + } + + public static function clearTeamCache($teamId) + { + Cache::forget(self::getCacheKey($teamId)); + } + + public function updatedSearchQuery() + { + $trimmedQuery = trim($this->searchQuery); + + // If only spaces were added/removed, don't trigger a search + if ($trimmedQuery === $this->previousTrimmedQuery) { + return; + } + + $this->previousTrimmedQuery = $trimmedQuery; + + // If search query is empty, just clear results without processing + if (empty($trimmedQuery)) { + $this->searchResults = []; + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->isSelectingResource = false; + $this->cancelResourceSelection(); + + return; + } + + $query = strtolower($trimmedQuery); + + // Reset keyboard navigation index + $this->dispatch('reset-selected-index'); + + // Only enter create mode if query is exactly "new" or starts with "new " (space after) + if ($query === 'new' || str_starts_with($query, 'new ')) { + $this->isCreateMode = true; + $this->loadCreatableItems(); + + // Check for sub-commands like "new project", "new server", etc. + $detectedType = $this->detectSpecificResource($query); + if ($detectedType) { + $this->navigateToResource($detectedType); + } else { + // If no specific resource detected, reset selection state + $this->cancelResourceSelection(); + } + + // Also search for existing resources that match the query + // This allows users to find resources with "new" in their name + $this->search(); + } else { + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->isSelectingResource = false; + $this->search(); + } + } + + private function detectSpecificResource(string $query): ?string + { + // Map of keywords to resource types - order matters for multi-word matches + $resourceMap = [ + // Quick Actions + 'new project' => 'project', + 'new server' => 'server', + 'new team' => 'team', + 'new storage' => 'storage', + 'new s3' => 'storage', + 'new private key' => 'private-key', + 'new privatekey' => 'private-key', + 'new key' => 'private-key', + 'new github app' => 'source', + 'new github' => 'source', + 'new source' => 'source', + + // Applications - Git-based + 'new public' => 'public', + 'new public git' => 'public', + 'new public repo' => 'public', + 'new public repository' => 'public', + 'new private github' => 'private-gh-app', + 'new private gh' => 'private-gh-app', + 'new private deploy' => 'private-deploy-key', + 'new deploy key' => 'private-deploy-key', + + // Applications - Docker-based + 'new dockerfile' => 'dockerfile', + 'new docker compose' => 'docker-compose-empty', + 'new compose' => 'docker-compose-empty', + 'new docker image' => 'docker-image', + 'new image' => 'docker-image', + + // Databases + 'new postgresql' => 'postgresql', + 'new postgres' => 'postgresql', + 'new mysql' => 'mysql', + 'new mariadb' => 'mariadb', + 'new redis' => 'redis', + 'new keydb' => 'keydb', + 'new dragonfly' => 'dragonfly', + 'new mongodb' => 'mongodb', + 'new mongo' => 'mongodb', + 'new clickhouse' => 'clickhouse', + ]; + + foreach ($resourceMap as $command => $type) { + if ($query === $command) { + // Check if user has permission for this resource type + if ($this->canCreateResource($type)) { + return $type; + } + } + } + + return null; + } + + private function canCreateResource(string $type): bool + { + $user = auth()->user(); + + // Quick Actions + if (in_array($type, ['server', 'storage', 'private-key'])) { + return $user->isAdmin() || $user->isOwner(); + } + + if ($type === 'team') { + return true; + } + + // Applications, Databases, Services, and other resources + if (in_array($type, [ + 'project', 'source', + // Applications + 'public', 'private-gh-app', 'private-deploy-key', + 'dockerfile', 'docker-compose-empty', 'docker-image', + // Databases + 'postgresql', 'mysql', 'mariadb', 'redis', 'keydb', + 'dragonfly', 'mongodb', 'clickhouse', + ]) || str_starts_with($type, 'one-click-service-')) { + return $user->can('createAnyResource'); + } + + return false; + } + + private function loadSearchableItems() + { + // Try to get from Redis cache first + $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + + $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + ray()->showQueries(); + $items = collect(); + $team = auth()->user()->currentTeam(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' application applications app apps'), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' service services'), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' postgresql '.$db->description.' database databases db'), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mysql '.$db->description.' database databases db'), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mariadb '.$db->description.' database databases db'), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mongodb '.$db->description.' database databases db'), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' redis '.$db->description.' database databases db'), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' keydb '.$db->description.' database databases db'), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' dragonfly '.$db->description.' database databases db'), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' clickhouse '.$db->description.' database databases db'), + ]; + }) + ); + + // Get all servers + $servers = Server::ownedByCurrentTeam() + ->get() + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), + ]; + }); + ray($servers); + // Get all projects + $projects = Project::ownedByCurrentTeam() + ->withCount(['environments', 'applications', 'services']) + ->get() + ->map(function ($project) { + $resourceCount = $project->applications_count + $project->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'type' => 'project', + 'uuid' => $project->uuid, + 'description' => $project->description, + 'link' => $project->navigateTo(), + 'project' => null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'environment_count' => $project->environments_count, + 'search_text' => strtolower($project->name.' '.$project->description.' project projects'), + ]; + }); + + // Get all environments + $environments = Environment::ownedByCurrentTeam() + ->with('project') + ->withCount(['applications', 'services']) + ->get() + ->map(function ($environment) { + $resourceCount = $environment->applications_count + $environment->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + // Build description with project context + $descriptionParts = []; + if ($environment->project) { + $descriptionParts[] = "Project: {$environment->project->name}"; + } + if ($environment->description) { + $descriptionParts[] = $environment->description; + } + if (empty($descriptionParts)) { + $descriptionParts[] = $resourceSummary; + } + + return [ + 'id' => $environment->id, + 'name' => $environment->name, + 'type' => 'environment', + 'uuid' => $environment->uuid, + 'description' => implode(' • ', $descriptionParts), + 'link' => route('project.resource.index', [ + 'project_uuid' => $environment->project->uuid, + 'environment_uuid' => $environment->uuid, + ]), + 'project' => $environment->project->name ?? null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), + ]; + }); + + // Add navigation routes + $navigation = collect([ + [ + 'name' => 'Dashboard', + 'type' => 'navigation', + 'description' => 'Go to main dashboard', + 'link' => route('dashboard'), + 'search_text' => 'dashboard home main overview', + ], + [ + 'name' => 'Servers', + 'type' => 'navigation', + 'description' => 'View all servers', + 'link' => route('server.index'), + 'search_text' => 'servers all list view', + ], + [ + 'name' => 'Projects', + 'type' => 'navigation', + 'description' => 'View all projects', + 'link' => route('project.index'), + 'search_text' => 'projects all list view', + ], + [ + 'name' => 'Destinations', + 'type' => 'navigation', + 'description' => 'View all destinations', + 'link' => route('destination.index'), + 'search_text' => 'destinations docker networks', + ], + [ + 'name' => 'Security', + 'type' => 'navigation', + 'description' => 'Manage private keys and API tokens', + 'link' => route('security.private-key.index'), + 'search_text' => 'security private keys ssh api tokens cloud-init scripts', + ], + [ + 'name' => 'Cloud-Init Scripts', + 'type' => 'navigation', + 'description' => 'Manage reusable cloud-init scripts', + 'link' => route('security.cloud-init-scripts'), + 'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup', + ], + [ + 'name' => 'Sources', + 'type' => 'navigation', + 'description' => 'Manage GitHub apps and Git sources', + 'link' => route('source.all'), + 'search_text' => 'sources github apps git repositories', + ], + [ + 'name' => 'Storages', + 'type' => 'navigation', + 'description' => 'Manage S3 storage for backups', + 'link' => route('storage.index'), + 'search_text' => 'storages s3 backups', + ], + [ + 'name' => 'Shared Variables', + 'type' => 'navigation', + 'description' => 'View all shared variables', + 'link' => route('shared-variables.index'), + 'search_text' => 'shared variables environment all', + ], + [ + 'name' => 'Team Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage team-wide shared variables', + 'link' => route('shared-variables.team.index'), + 'search_text' => 'shared variables team environment', + ], + [ + 'name' => 'Project Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage project shared variables', + 'link' => route('shared-variables.project.index'), + 'search_text' => 'shared variables project environment', + ], + [ + 'name' => 'Environment Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage environment shared variables', + 'link' => route('shared-variables.environment.index'), + 'search_text' => 'shared variables environment', + ], + [ + 'name' => 'Tags', + 'type' => 'navigation', + 'description' => 'View resources by tags', + 'link' => route('tags.show'), + 'search_text' => 'tags labels organize', + ], + [ + 'name' => 'Terminal', + 'type' => 'navigation', + 'description' => 'Access server terminal', + 'link' => route('terminal'), + 'search_text' => 'terminal ssh console shell command line', + ], + [ + 'name' => 'Profile', + 'type' => 'navigation', + 'description' => 'Manage your profile and preferences', + 'link' => route('profile'), + 'search_text' => 'profile account user settings preferences', + ], + [ + 'name' => 'Team', + 'type' => 'navigation', + 'description' => 'Manage team members and settings', + 'link' => route('team.index'), + 'search_text' => 'team settings members users invitations', + ], + [ + 'name' => 'Notifications', + 'type' => 'navigation', + 'description' => 'Configure email, Discord, Telegram notifications', + 'link' => route('notifications.email'), + 'search_text' => 'notifications alerts email discord telegram slack pushover', + ], + ]); + + // Add instance settings only for self-hosted and root team + if (! isCloud() && $team->id === 0) { + $navigation->push([ + 'name' => 'Settings', + 'type' => 'navigation', + 'description' => 'Instance settings and configuration', + 'link' => route('settings.index'), + 'search_text' => 'settings configuration instance', + ]); + } + + // Merge all collections + $items = $items->merge($navigation) + ->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers) + ->merge($projects) + ->merge($environments); + + return $items->toArray(); + }); + } + + private function search() + { + if (strlen($this->searchQuery) < 1) { + $this->searchResults = []; + + return; + } + + $query = strtolower($this->searchQuery); + + // Detect resource category queries + $categoryMapping = [ + 'server' => ['server', 'type' => 'server'], + 'servers' => ['server', 'type' => 'server'], + 'app' => ['application', 'type' => 'application'], + 'apps' => ['application', 'type' => 'application'], + 'application' => ['application', 'type' => 'application'], + 'applications' => ['application', 'type' => 'application'], + 'db' => ['database', 'type' => 'standalone-postgresql'], + 'database' => ['database', 'type' => 'standalone-postgresql'], + 'databases' => ['database', 'type' => 'standalone-postgresql'], + 'service' => ['service', 'category' => 'Services'], + 'services' => ['service', 'category' => 'Services'], + 'project' => ['project', 'type' => 'project'], + 'projects' => ['project', 'type' => 'project'], + ]; + + $priorityCreatableItem = null; + + // Check if query matches a resource category + if (isset($categoryMapping[$query])) { + $this->loadCreatableItems(); + $mapping = $categoryMapping[$query]; + + // Find the matching creatable item + $priorityCreatableItem = collect($this->creatableItems) + ->first(function ($item) use ($mapping) { + if (isset($mapping['type'])) { + return $item['type'] === $mapping['type']; + } + if (isset($mapping['category'])) { + return isset($item['category']) && $item['category'] === $mapping['category']; + } + + return false; + }); + + if ($priorityCreatableItem) { + $priorityCreatableItem['is_creatable_suggestion'] = true; + } + } + + // Search for matching creatable resources to show as suggestions (if no priority item) + if (! $priorityCreatableItem) { + $this->loadCreatableItems(); + + // Search in regular creatable items (apps, databases, quick actions) + $creatableSuggestions = collect($this->creatableItems) + ->filter(function ($item) use ($query) { + $searchText = strtolower($item['name'].' '.$item['description'].' '.($item['type'] ?? '')); + + // Use word boundary matching to avoid substring matches (e.g., "wordpress" shouldn't match "classicpress") + return preg_match('/\b'.preg_quote($query, '/').'/i', $searchText); + }) + ->map(function ($item) use ($query) { + // Calculate match priority: name > type > description + $name = strtolower($item['name']); + $type = strtolower($item['type'] ?? ''); + $description = strtolower($item['description']); + + if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) { + $item['match_priority'] = 1; + } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) { + $item['match_priority'] = 2; + } else { + $item['match_priority'] = 3; + } + + $item['is_creatable_suggestion'] = true; + + return $item; + }); + + // Also search in services (loaded on-demand) + $serviceSuggestions = collect($this->services) + ->filter(function ($item) use ($query) { + $searchText = strtolower($item['name'].' '.$item['description'].' '.($item['type'] ?? '')); + + return preg_match('/\b'.preg_quote($query, '/').'/i', $searchText); + }) + ->map(function ($item) use ($query) { + // Calculate match priority: name > type > description + $name = strtolower($item['name']); + $type = strtolower($item['type'] ?? ''); + $description = strtolower($item['description']); + + if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) { + $item['match_priority'] = 1; + } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) { + $item['match_priority'] = 2; + } else { + $item['match_priority'] = 3; + } + + $item['is_creatable_suggestion'] = true; + + return $item; + }); + + // Merge and sort all suggestions + $creatableSuggestions = $creatableSuggestions + ->merge($serviceSuggestions) + ->sortBy('match_priority') + ->take(10) + ->values() + ->toArray(); + } else { + $creatableSuggestions = []; + } + + // Case-insensitive search in existing resources + $existingResults = collect($this->allSearchableItems) + ->filter(function ($item) use ($query) { + // Use word boundary matching to avoid substring matches (e.g., "wordpress" shouldn't match "classicpress") + return preg_match('/\b'.preg_quote($query, '/').'/i', $item['search_text']); + }) + ->map(function ($item) use ($query) { + // Calculate match priority: name > type > description + $name = strtolower($item['name'] ?? ''); + $type = strtolower($item['type'] ?? ''); + $description = strtolower($item['description'] ?? ''); + + if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) { + $item['match_priority'] = 1; + } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) { + $item['match_priority'] = 2; + } else { + $item['match_priority'] = 3; + } + + return $item; + }) + ->sortBy('match_priority') + ->take(20) + ->values() + ->toArray(); + + // Merge results: existing resources first, then priority create item, then other creatable suggestions + $results = []; + + // If we have existing results, show them first + $results = array_merge($results, $existingResults); + + // Then show the priority "Create New" item (if exists) + if ($priorityCreatableItem) { + $results[] = $priorityCreatableItem; + } + + // Finally show other creatable suggestions + $results = array_merge($results, $creatableSuggestions); + + $this->searchResults = $results; + } + + private function loadCreatableItems() + { + $items = collect(); + $user = auth()->user(); + + // === Quick Actions Category === + + // Project - can be created if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'Project', + 'description' => 'Create a new project to organize your resources', + 'quickcommand' => '(type: new project)', + 'type' => 'project', + 'category' => 'Quick Actions', + 'component' => 'project.add-empty', + ]); + } + + // Server - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'Server', + 'description' => 'Add a new server to deploy your applications', + 'quickcommand' => '(type: new server)', + 'type' => 'server', + 'category' => 'Quick Actions', + 'component' => 'server.create', + ]); + } + + // Team - can be created by anyone (they become owner of new team) + $items->push([ + 'name' => 'Team', + 'description' => 'Create a new team to collaborate with others', + 'quickcommand' => '(type: new team)', + 'type' => 'team', + 'category' => 'Quick Actions', + 'component' => 'team.create', + ]); + + // Storage - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'S3 Storage', + 'description' => 'Add S3 storage for backups and file uploads', + 'quickcommand' => '(type: new storage)', + 'type' => 'storage', + 'category' => 'Quick Actions', + 'component' => 'storage.create', + ]); + } + + // Private Key - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'Private Key', + 'description' => 'Add an SSH private key for server access', + 'quickcommand' => '(type: new private key)', + 'type' => 'private-key', + 'category' => 'Quick Actions', + 'component' => 'security.private-key.create', + ]); + } + + // GitHub Source - can be created if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'GitHub App', + 'description' => 'Connect a GitHub app for source control', + 'quickcommand' => '(type: new github)', + 'type' => 'source', + 'category' => 'Quick Actions', + 'component' => 'source.github.create', + ]); + } + + // === Applications Category === + + if ($user->can('createAnyResource')) { + // Git-based applications + $items->push([ + 'name' => 'Public Git Repository', + 'description' => 'Deploy from any public Git repository', + 'quickcommand' => '(type: new public)', + 'type' => 'public', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Private Repository (GitHub App)', + 'description' => 'Deploy private repositories through GitHub Apps', + 'quickcommand' => '(type: new private github)', + 'type' => 'private-gh-app', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Private Repository (Deploy Key)', + 'description' => 'Deploy private repositories with a deploy key', + 'quickcommand' => '(type: new private deploy)', + 'type' => 'private-deploy-key', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + // Docker-based applications + $items->push([ + 'name' => 'Dockerfile', + 'description' => 'Deploy a simple Dockerfile without Git', + 'quickcommand' => '(type: new dockerfile)', + 'type' => 'dockerfile', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Docker Compose', + 'description' => 'Deploy complex applications with Docker Compose', + 'quickcommand' => '(type: new compose)', + 'type' => 'docker-compose-empty', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Docker Image', + 'description' => 'Deploy an existing Docker image from any registry', + 'quickcommand' => '(type: new image)', + 'type' => 'docker-image', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + } + + // === Databases Category === + + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'PostgreSQL', + 'description' => 'Robust, advanced open-source database', + 'quickcommand' => '(type: new postgresql)', + 'type' => 'postgresql', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'MySQL', + 'description' => 'Popular open-source relational database', + 'quickcommand' => '(type: new mysql)', + 'type' => 'mysql', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'MariaDB', + 'description' => 'Community-developed fork of MySQL', + 'quickcommand' => '(type: new mariadb)', + 'type' => 'mariadb', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'Redis', + 'description' => 'In-memory data structure store', + 'quickcommand' => '(type: new redis)', + 'type' => 'redis', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'KeyDB', + 'description' => 'High-performance Redis alternative', + 'quickcommand' => '(type: new keydb)', + 'type' => 'keydb', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'Dragonfly', + 'description' => 'Modern in-memory datastore', + 'quickcommand' => '(type: new dragonfly)', + 'type' => 'dragonfly', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'MongoDB', + 'description' => 'Document-oriented NoSQL database', + 'quickcommand' => '(type: new mongodb)', + 'type' => 'mongodb', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'Clickhouse', + 'description' => 'Column-oriented database for analytics', + 'quickcommand' => '(type: new clickhouse)', + 'type' => 'clickhouse', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + } + + // Merge with services + $items = $items->merge(collect($this->services)); + + $this->creatableItems = $items->toArray(); + } + + public function navigateToResource($type) + { + // Find the item by type - check regular items first, then services + $item = collect($this->creatableItems)->firstWhere('type', $type); + + if (! $item) { + $item = collect($this->services)->firstWhere('type', $type); + } + + if (! $item) { + return; + } + + // If it has a component, it's a modal-based resource + // Close search modal and open the appropriate creation modal + if (isset($item['component'])) { + $this->dispatch('closeSearchModal'); + $this->dispatch('open-create-modal-'.$type); + + return; + } + + // For applications, databases, and services, navigate to resource creation + // with smart defaults (auto-select if only 1 server/project/environment) + if (isset($item['resourceType'])) { + $this->navigateToResourceCreation($type); + } + } + + private function navigateToResourceCreation($type) + { + // Start the selection flow + $this->selectedResourceType = $type; + $this->isSelectingResource = true; + + // Clear search query to show selection UI instead of creatable items + $this->searchQuery = ''; + + // Reset selections + $this->selectedServerId = null; + $this->selectedDestinationUuid = null; + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + + // Start loading servers first (in order: servers -> destinations -> projects -> environments) + $this->loadServers(); + } + + public function loadServers() + { + $this->loadingServers = true; + $servers = Server::isUsable()->get()->sortBy('name'); + $this->availableServers = $servers->map(fn ($s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'description' => $s->description, + ])->toArray(); + $this->loadingServers = false; + + // Auto-select if only one server + if (count($this->availableServers) === 1) { + $this->selectServer($this->availableServers[0]['id']); + } + } + + public function selectServer($serverId, $shouldProgress = true) + { + $this->selectedServerId = $serverId; + + if ($shouldProgress) { + $this->loadDestinations(); + } + } + + public function loadDestinations() + { + $this->loadingDestinations = true; + $server = Server::find($this->selectedServerId); + + if (! $server) { + $this->loadingDestinations = false; + + return $this->dispatch('error', message: 'Server not found'); + } + + $destinations = $server->destinations(); + + if ($destinations->isEmpty()) { + $this->loadingDestinations = false; + + return $this->dispatch('error', message: 'No destinations found on this server'); + } + + $this->availableDestinations = $destinations->map(fn ($d) => [ + 'uuid' => $d->uuid, + 'name' => $d->name, + 'network' => $d->network ?? 'default', + ])->toArray(); + + $this->loadingDestinations = false; + + // Auto-select if only one destination + if (count($this->availableDestinations) === 1) { + $this->selectDestination($this->availableDestinations[0]['uuid']); + } + } + + public function selectDestination($destinationUuid, $shouldProgress = true) + { + $this->selectedDestinationUuid = $destinationUuid; + + if ($shouldProgress) { + $this->loadProjects(); + } + } + + public function loadProjects() + { + $this->loadingProjects = true; + $user = auth()->user(); + $team = $user->currentTeam(); + $projects = Project::where('team_id', $team->id)->get(); + + if ($projects->isEmpty()) { + $this->loadingProjects = false; + + return $this->dispatch('error', message: 'Please create a project first'); + } + + $this->availableProjects = $projects->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'name' => $p->name, + 'description' => $p->description, + ])->toArray(); + $this->loadingProjects = false; + + // Auto-select if only one project + if (count($this->availableProjects) === 1) { + $this->selectProject($this->availableProjects[0]['uuid']); + } + } + + public function selectProject($projectUuid, $shouldProgress = true) + { + $this->selectedProjectUuid = $projectUuid; + + if ($shouldProgress) { + $this->loadEnvironments(); + } + } + + public function loadEnvironments() + { + $this->loadingEnvironments = true; + $project = Project::where('uuid', $this->selectedProjectUuid)->first(); + + if (! $project) { + $this->loadingEnvironments = false; + + return; + } + + $environments = $project->environments; + + if ($environments->isEmpty()) { + $this->loadingEnvironments = false; + + return $this->dispatch('error', message: 'No environments found in project'); + } + + $this->availableEnvironments = $environments->map(fn ($e) => [ + 'uuid' => $e->uuid, + 'name' => $e->name, + 'description' => $e->description, + ])->toArray(); + $this->loadingEnvironments = false; + + // Auto-select if only one environment + if (count($this->availableEnvironments) === 1) { + $this->selectEnvironment($this->availableEnvironments[0]['uuid']); + } + } + + public function selectEnvironment($environmentUuid, $shouldProgress = true) + { + $this->selectedEnvironmentUuid = $environmentUuid; + + if ($shouldProgress) { + $this->completeResourceCreation(); + } + } + + private function completeResourceCreation() + { + // All selections made - navigate to resource creation + if ($this->selectedProjectUuid && $this->selectedEnvironmentUuid && $this->selectedResourceType && $this->selectedServerId !== null && $this->selectedDestinationUuid) { + $queryParams = [ + 'type' => $this->selectedResourceType, + 'destination' => $this->selectedDestinationUuid, + 'server_id' => $this->selectedServerId, + ]; + + // PostgreSQL requires a database_image parameter + if ($this->selectedResourceType === 'postgresql') { + $queryParams['database_image'] = 'postgres:16-alpine'; + } + + $this->redirect(route('project.resource.create', [ + 'project_uuid' => $this->selectedProjectUuid, + 'environment_uuid' => $this->selectedEnvironmentUuid, + ] + $queryParams)); + } + } + + public function cancelResourceSelection() + { + $this->isSelectingResource = false; + $this->selectedResourceType = null; + $this->selectedServerId = null; + $this->selectedDestinationUuid = null; + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + $this->availableServers = []; + $this->availableDestinations = []; + $this->availableProjects = []; + $this->availableEnvironments = []; + $this->autoOpenResource = null; + } + + public function getFilteredCreatableItemsProperty() + { + $query = strtolower(trim($this->searchQuery)); + + // Check if query matches a category keyword + $categoryKeywords = ['server', 'servers', 'app', 'apps', 'application', 'applications', 'db', 'database', 'databases', 'service', 'services', 'project', 'projects']; + if (in_array($query, $categoryKeywords)) { + return $this->filterCreatableItemsByCategory($query); + } + + // Extract search term - everything after "new " + if (str_starts_with($query, 'new ')) { + $searchTerm = trim(substr($query, strlen('new '))); + + if (empty($searchTerm)) { + return $this->creatableItems; + } + + // Filter items by name or description + return collect($this->creatableItems)->filter(function ($item) use ($searchTerm) { + $searchText = strtolower($item['name'].' '.$item['description'].' '.$item['category']); + + return str_contains($searchText, $searchTerm); + })->values()->toArray(); + } + + return $this->creatableItems; + } + + private function filterCreatableItemsByCategory($categoryKeyword) + { + // Map keywords to category names + $categoryMap = [ + 'server' => 'Quick Actions', + 'servers' => 'Quick Actions', + 'app' => 'Applications', + 'apps' => 'Applications', + 'application' => 'Applications', + 'applications' => 'Applications', + 'db' => 'Databases', + 'database' => 'Databases', + 'databases' => 'Databases', + 'service' => 'Services', + 'services' => 'Services', + 'project' => 'Applications', + 'projects' => 'Applications', + ]; + + $category = $categoryMap[$categoryKeyword] ?? null; + + if (! $category) { + return []; + } + + return collect($this->creatableItems) + ->filter(fn ($item) => $item['category'] === $category) + ->values() + ->toArray(); + } + + public function getSelectedResourceNameProperty() + { + if (! $this->selectedResourceType) { + return null; + } + + // Load creatable items if not loaded yet + if (empty($this->creatableItems)) { + $this->loadCreatableItems(); + } + + // Find the item by type - check regular items first, then services + $item = collect($this->creatableItems)->firstWhere('type', $this->selectedResourceType); + + if (! $item) { + $item = collect($this->services)->firstWhere('type', $this->selectedResourceType); + } + + return $item ? $item['name'] : null; + } + + public function getServicesProperty() + { + // Cache services in a static property to avoid reloading on every access + static $cachedServices = null; + + if ($cachedServices !== null) { + return $cachedServices; + } + + $user = auth()->user(); + + if (! $user->can('createAnyResource')) { + $cachedServices = []; + + return $cachedServices; + } + + // Load all services + $allServices = get_service_templates(); + $items = collect(); + + foreach ($allServices as $serviceKey => $service) { + $items->push([ + 'name' => str($serviceKey)->headline()->toString(), + 'description' => data_get($service, 'slogan', 'Deploy '.str($serviceKey)->headline()), + 'type' => 'one-click-service-'.$serviceKey, + 'category' => 'Services', + 'resourceType' => 'service', + ]); + } + + $cachedServices = $items->toArray(); + + return $cachedServices; + } + + public function render() + { + return view('livewire.global-search'); + } +} diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 913710588..490515875 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -42,7 +42,7 @@ public function submit() 'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`', ]); } else { - send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io'); + send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io'); } $this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.'); $this->reset('description', 'subject'); diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php index 53ca1d386..f660f9c13 100644 --- a/app/Livewire/MonacoEditor.php +++ b/app/Livewire/MonacoEditor.php @@ -25,6 +25,7 @@ public function __construct( public bool $readonly, public bool $allowTab, public bool $spellcheck, + public bool $autofocus, public ?string $helper, public bool $realtimeValidation, public bool $allowToPeak, diff --git a/app/Livewire/NewActivityMonitor.php b/app/Livewire/NewActivityMonitor.php deleted file mode 100644 index a9334e710..000000000 --- a/app/Livewire/NewActivityMonitor.php +++ /dev/null @@ -1,74 +0,0 @@ - 'newMonitorActivity']; - - public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null) - { - $this->activityId = $activityId; - $this->eventToDispatch = $eventToDispatch; - $this->eventData = $eventData; - - $this->hydrateActivity(); - - $this->isPollingActive = true; - } - - public function hydrateActivity() - { - $this->activity = Activity::find($this->activityId); - } - - public function polling() - { - $this->hydrateActivity(); - // $this->setStatus(ProcessStatus::IN_PROGRESS); - $exit_code = data_get($this->activity, 'properties.exitCode'); - if ($exit_code !== null) { - // if ($exit_code === 0) { - // // $this->setStatus(ProcessStatus::FINISHED); - // } else { - // // $this->setStatus(ProcessStatus::ERROR); - // } - $this->isPollingActive = false; - if ($this->eventToDispatch !== null) { - if (str($this->eventToDispatch)->startsWith('App\\Events\\')) { - $causer_id = data_get($this->activity, 'causer_id'); - $user = User::find($causer_id); - if ($user) { - foreach ($user->teams as $team) { - $teamId = $team->id; - $this->eventToDispatch::dispatch($teamId); - } - } - - return; - } - if (! is_null($this->eventData)) { - $this->dispatch($this->eventToDispatch, $this->eventData); - } else { - $this->dispatch($this->eventToDispatch); - } - } - } - } -} diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 9489eb128..28d1cb866 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -5,11 +5,14 @@ use App\Models\DiscordNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Discord extends Component { + use AuthorizesRequests; + public Team $team; public DiscordNotificationSettings $settings; @@ -56,6 +59,9 @@ class Discord extends Component #[Validate(['boolean'])] public bool $serverUnreachableDiscordNotifications = true; + #[Validate(['boolean'])] + public bool $serverPatchDiscordNotifications = false; + #[Validate(['boolean'])] public bool $discordPingEnabled = true; @@ -64,6 +70,7 @@ public function mount() try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->discordNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -74,6 +81,7 @@ public function syncData(bool $toModel = false) { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_webhook_url = $this->discordWebhookUrl; @@ -89,6 +97,7 @@ public function syncData(bool $toModel = false) $this->settings->server_disk_usage_discord_notifications = $this->serverDiskUsageDiscordNotifications; $this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications; $this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications; + $this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications; $this->settings->discord_ping_enabled = $this->discordPingEnabled; @@ -110,6 +119,7 @@ public function syncData(bool $toModel = false) $this->serverDiskUsageDiscordNotifications = $this->settings->server_disk_usage_discord_notifications; $this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications; $this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications; + $this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications; $this->discordPingEnabled = $this->settings->discord_ping_enabled; } @@ -177,6 +187,7 @@ public function saveModel() public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'discord')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index c5f518e16..d62a08417 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -5,6 +5,7 @@ use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -12,6 +13,8 @@ class Email extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -98,6 +101,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $serverUnreachableEmailNotifications = true; + #[Validate(['boolean'])] + public bool $serverPatchEmailNotifications = false; + #[Validate(['nullable', 'email'])] public ?string $testEmailAddress = null; @@ -107,6 +113,7 @@ public function mount() $this->team = auth()->user()->currentTeam(); $this->emails = auth()->user()->email; $this->settings = $this->team->emailNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); $this->testEmailAddress = auth()->user()->email; } catch (\Throwable $e) { @@ -118,6 +125,7 @@ public function syncData(bool $toModel = false) { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -146,6 +154,7 @@ public function syncData(bool $toModel = false) $this->settings->server_disk_usage_email_notifications = $this->serverDiskUsageEmailNotifications; $this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications; $this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications; + $this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications; $this->settings->save(); } else { @@ -177,6 +186,7 @@ public function syncData(bool $toModel = false) $this->serverDiskUsageEmailNotifications = $this->settings->server_disk_usage_email_notifications; $this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications; $this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications; + $this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications; } } @@ -249,10 +259,9 @@ public function submitSmtp() 'smtpEncryption.required' => 'Encryption type is required.', ]); - $this->settings->resend_enabled = false; - $this->settings->use_instance_email_settings = false; - $this->resendEnabled = false; - $this->useInstanceEmailSettings = false; + if ($this->smtpEnabled) { + $this->settings->resend_enabled = $this->resendEnabled = false; + } $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; @@ -288,11 +297,9 @@ public function submitResend() 'smtpFromAddress.email' => 'Please enter a valid email address.', 'smtpFromName.required' => 'From Name is required.', ]); - - $this->settings->smtp_enabled = false; - $this->settings->use_instance_email_settings = false; - $this->smtpEnabled = false; - $this->useInstanceEmailSettings = false; + if ($this->resendEnabled) { + $this->settings->smtp_enabled = $this->smtpEnabled = false; + } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -309,6 +316,7 @@ public function submitResend() public function sendTestEmail() { try { + $this->authorize('sendTest', $this->settings); $this->validate([ 'testEmailAddress' => 'required|email', ], [ @@ -320,7 +328,7 @@ public function sendTestEmail() 'test-email:'.$this->team->id, $perMinute = 0, function () { - $this->team?->notify(new Test($this->testEmailAddress, 'email')); + $this->team?->notifyNow(new Test($this->testEmailAddress, 'email')); $this->dispatch('success', 'Test Email sent.'); }, $decaySeconds = 10, @@ -336,6 +344,7 @@ function () { public function copyFromInstanceSettings() { + $this->authorize('update', $this->settings); $settings = instanceSettings(); $this->smtpFromAddress = $settings->smtp_from_address; $this->smtpFromName = $settings->smtp_from_name; diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index f1e4c464d..9c7ff64ad 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -5,12 +5,15 @@ use App\Models\PushoverNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Pushover extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -64,11 +67,15 @@ class Pushover extends Component #[Validate(['boolean'])] public bool $serverUnreachablePushoverNotifications = true; + #[Validate(['boolean'])] + public bool $serverPatchPushoverNotifications = false; + public function mount() { try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->pushoverNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -79,6 +86,7 @@ public function syncData(bool $toModel = false) { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_api_token = $this->pushoverApiToken; @@ -95,6 +103,7 @@ public function syncData(bool $toModel = false) $this->settings->server_disk_usage_pushover_notifications = $this->serverDiskUsagePushoverNotifications; $this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications; $this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications; + $this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications; $this->settings->save(); refreshSession(); @@ -115,6 +124,7 @@ public function syncData(bool $toModel = false) $this->serverDiskUsagePushoverNotifications = $this->settings->server_disk_usage_pushover_notifications; $this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications; $this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications; + $this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications; } } @@ -170,6 +180,7 @@ public function saveModel() public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'pushover')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index f47ad8a97..d21399c42 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -5,12 +5,15 @@ use App\Models\SlackNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Slack extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -61,11 +64,15 @@ class Slack extends Component #[Validate(['boolean'])] public bool $serverUnreachableSlackNotifications = true; + #[Validate(['boolean'])] + public bool $serverPatchSlackNotifications = false; + public function mount() { try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->slackNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -76,6 +83,7 @@ public function syncData(bool $toModel = false) { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->slack_enabled = $this->slackEnabled; $this->settings->slack_webhook_url = $this->slackWebhookUrl; @@ -91,6 +99,7 @@ public function syncData(bool $toModel = false) $this->settings->server_disk_usage_slack_notifications = $this->serverDiskUsageSlackNotifications; $this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications; $this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications; + $this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications; $this->settings->save(); refreshSession(); @@ -110,6 +119,7 @@ public function syncData(bool $toModel = false) $this->serverDiskUsageSlackNotifications = $this->settings->server_disk_usage_slack_notifications; $this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications; $this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications; + $this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications; } } @@ -163,6 +173,7 @@ public function saveModel() public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'slack')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 01afb4ac6..ca9df47c1 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -5,12 +5,15 @@ use App\Models\Team; use App\Models\TelegramNotificationSettings; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Telegram extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -64,6 +67,9 @@ class Telegram extends Component #[Validate(['boolean'])] public bool $serverUnreachableTelegramNotifications = true; + #[Validate(['boolean'])] + public bool $serverPatchTelegramNotifications = false; + #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsDeploymentSuccessThreadId = null; @@ -100,11 +106,15 @@ class Telegram extends Component #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsServerUnreachableThreadId = null; + #[Validate(['nullable', 'string'])] + public ?string $telegramNotificationsServerPatchThreadId = null; + public function mount() { try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->telegramNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -115,6 +125,7 @@ public function syncData(bool $toModel = false) { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->telegram_enabled = $this->telegramEnabled; $this->settings->telegram_token = $this->telegramToken; $this->settings->telegram_chat_id = $this->telegramChatId; @@ -131,6 +142,7 @@ public function syncData(bool $toModel = false) $this->settings->server_disk_usage_telegram_notifications = $this->serverDiskUsageTelegramNotifications; $this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications; $this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications; + $this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications; $this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId; $this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId; @@ -144,6 +156,7 @@ public function syncData(bool $toModel = false) $this->settings->telegram_notifications_server_disk_usage_thread_id = $this->telegramNotificationsServerDiskUsageThreadId; $this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId; $this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId; + $this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId; $this->settings->save(); } else { @@ -163,6 +176,7 @@ public function syncData(bool $toModel = false) $this->serverDiskUsageTelegramNotifications = $this->settings->server_disk_usage_telegram_notifications; $this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications; $this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications; + $this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications; $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; @@ -176,6 +190,7 @@ public function syncData(bool $toModel = false) $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; + $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; } } @@ -231,6 +246,7 @@ public function saveModel() public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'telegram')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php new file mode 100644 index 000000000..cf4e71105 --- /dev/null +++ b/app/Livewire/Notifications/Webhook.php @@ -0,0 +1,196 @@ +team = auth()->user()->currentTeam(); + $this->settings = $this->team->webhookNotificationSettings; + $this->authorize('view', $this->settings); + $this->syncData(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->authorize('update', $this->settings); + $this->settings->webhook_enabled = $this->webhookEnabled; + $this->settings->webhook_url = $this->webhookUrl; + + $this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications; + $this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications; + $this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications; + $this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications; + $this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications; + $this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications; + $this->settings->scheduled_task_failure_webhook_notifications = $this->scheduledTaskFailureWebhookNotifications; + $this->settings->docker_cleanup_success_webhook_notifications = $this->dockerCleanupSuccessWebhookNotifications; + $this->settings->docker_cleanup_failure_webhook_notifications = $this->dockerCleanupFailureWebhookNotifications; + $this->settings->server_disk_usage_webhook_notifications = $this->serverDiskUsageWebhookNotifications; + $this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications; + $this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications; + $this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications; + + $this->settings->save(); + refreshSession(); + } else { + $this->webhookEnabled = $this->settings->webhook_enabled; + $this->webhookUrl = $this->settings->webhook_url; + + $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; + $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; + $this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications; + $this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications; + $this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications; + $this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications; + $this->scheduledTaskFailureWebhookNotifications = $this->settings->scheduled_task_failure_webhook_notifications; + $this->dockerCleanupSuccessWebhookNotifications = $this->settings->docker_cleanup_success_webhook_notifications; + $this->dockerCleanupFailureWebhookNotifications = $this->settings->docker_cleanup_failure_webhook_notifications; + $this->serverDiskUsageWebhookNotifications = $this->settings->server_disk_usage_webhook_notifications; + $this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications; + $this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications; + $this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications; + } + } + + public function instantSaveWebhookEnabled() + { + try { + $original = $this->webhookEnabled; + $this->validate([ + 'webhookUrl' => 'required', + ], [ + 'webhookUrl.required' => 'Webhook URL is required.', + ]); + $this->saveModel(); + } catch (\Throwable $e) { + $this->webhookEnabled = $original; + + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->syncData(true); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submit() + { + try { + $this->resetErrorBag(); + $this->syncData(true); + $this->saveModel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function saveModel() + { + $this->syncData(true); + refreshSession(); + + if (isDev()) { + ray('Webhook settings saved', [ + 'webhook_enabled' => $this->settings->webhook_enabled, + 'webhook_url' => $this->settings->webhook_url, + ]); + } + + $this->dispatch('success', 'Settings saved.'); + } + + public function sendTestNotification() + { + try { + $this->authorize('sendTest', $this->settings); + + if (isDev()) { + ray('Sending test webhook notification', [ + 'team_id' => $this->team->id, + 'webhook_url' => $this->settings->webhook_url, + ]); + } + + $this->team->notify(new Test(channel: 'webhook')); + $this->dispatch('success', 'Test notification sent.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.notifications.webhook'); + } +} diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 788802353..4a419a12f 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; @@ -23,11 +24,25 @@ class Index extends Component #[Validate('required')] public string $name; + public string $new_email = ''; + + public string $email_verification_code = ''; + + public bool $show_email_change = false; + + public bool $show_verification = false; + public function mount() { $this->userId = Auth::id(); $this->name = Auth::user()->name; $this->email = Auth::user()->email; + + // Check if there's a pending email change + if (Auth::user()->hasEmailChangeRequest()) { + $this->new_email = Auth::user()->pending_email; + $this->show_verification = true; + } } public function submit() @@ -46,6 +61,182 @@ public function submit() } } + public function requestEmailChange() + { + try { + // For self-hosted, check if email is enabled + if (! isCloud()) { + $settings = instanceSettings(); + if (! $settings->smtp_enabled && ! $settings->resend_enabled) { + $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.'); + + return; + } + } + + $this->validate([ + 'new_email' => ['required', 'email', 'unique:users,email'], + ]); + + $this->new_email = strtolower($this->new_email); + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit by current user's email (1 request per 2 minutes) + $userEmailKey = 'email-change:user:'.Auth::id(); + if (! RateLimiter::attempt($userEmailKey, 1, function () {}, 120)) { + $seconds = RateLimiter::availableIn($userEmailKey); + $this->dispatch('error', 'Too many requests. Please wait '.$seconds.' seconds before trying again.'); + + return; + } + + // Rate limit by new email address (3 requests per hour per email) + $newEmailKey = 'email-change:email:'.md5($this->new_email); + if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) { + $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.'); + + return; + } + + // Additional rate limit by IP address (5 requests per hour) + $ipKey = 'email-change:ip:'.request()->ip(); + if (! RateLimiter::attempt($ipKey, 5, function () {}, 3600)) { + $this->dispatch('error', 'Too many requests from your IP address. Please try again later.'); + + return; + } + } + + Auth::user()->requestEmailChange($this->new_email); + + $this->show_email_change = false; + $this->show_verification = true; + + $this->dispatch('success', 'Verification code sent to '.$this->new_email); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function verifyEmailChange() + { + try { + $this->validate([ + 'email_verification_code' => ['required', 'string', 'size:6'], + ]); + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit verification attempts (5 attempts per 10 minutes) + $verifyKey = 'email-verify:user:'.Auth::id(); + if (! RateLimiter::attempt($verifyKey, 5, function () {}, 600)) { + $seconds = RateLimiter::availableIn($verifyKey); + $minutes = ceil($seconds / 60); + $this->dispatch('error', 'Too many verification attempts. Please wait '.$minutes.' minutes before trying again.'); + + // If too many failed attempts, clear the email change request for security + if (RateLimiter::attempts($verifyKey) >= 10) { + Auth::user()->clearEmailChangeRequest(); + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_verification = false; + $this->dispatch('error', 'Email change request cancelled due to too many failed attempts. Please start over.'); + } + + return; + } + } + + if (! Auth::user()->isEmailChangeCodeValid($this->email_verification_code)) { + $this->dispatch('error', 'Invalid or expired verification code.'); + + return; + } + + if (Auth::user()->confirmEmailChange($this->email_verification_code)) { + // Clear rate limiters on successful verification (only in production) + if (! isDev()) { + $verifyKey = 'email-verify:user:'.Auth::id(); + RateLimiter::clear($verifyKey); + } + + $this->email = Auth::user()->email; + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_verification = false; + + $this->dispatch('success', 'Email address updated successfully.'); + } else { + $this->dispatch('error', 'Failed to update email address.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function resendVerificationCode() + { + try { + // Check if there's a pending request + if (! Auth::user()->hasEmailChangeRequest()) { + $this->dispatch('error', 'No pending email change request.'); + + return; + } + + // Check if enough time has passed (at least half of the expiry time) + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + $halfExpiryMinutes = $expiryMinutes / 2; + $codeExpiry = Auth::user()->email_change_code_expires_at; + $timeSinceCreated = $codeExpiry->subMinutes($expiryMinutes)->diffInMinutes(now()); + + if ($timeSinceCreated < $halfExpiryMinutes) { + $minutesToWait = ceil($halfExpiryMinutes - $timeSinceCreated); + $this->dispatch('error', 'Please wait '.$minutesToWait.' more minutes before requesting a new code.'); + + return; + } + + $pendingEmail = Auth::user()->pending_email; + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit by email address + $newEmailKey = 'email-change:email:'.md5(strtolower($pendingEmail)); + if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) { + $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.'); + + return; + } + } + + // Generate and send new code + Auth::user()->requestEmailChange($pendingEmail); + + $this->dispatch('success', 'New verification code sent to '.$pendingEmail); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function cancelEmailChange() + { + Auth::user()->clearEmailChangeRequest(); + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_email_change = false; + $this->show_verification = false; + + $this->dispatch('success', 'Email change request cancelled.'); + } + + public function showEmailChangeForm() + { + $this->show_email_change = true; + $this->new_email = ''; + } + public function resetPassword() { try { diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 07873c059..974f0608a 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -3,18 +3,29 @@ namespace App\Livewire\Project; use App\Models\Project; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; class AddEmpty extends Component { - #[Validate(['required', 'string', 'min:3'])] public string $name; - #[Validate(['nullable', 'string'])] public string $description = ''; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function submit() { try { @@ -26,7 +37,12 @@ public function submit() 'uuid' => (string) new Cuid2, ]); - return redirect()->route('project.show', $project->uuid); + $productionEnvironment = $project->environments()->where('name', 'production')->first(); + + return redirect()->route('project.resource.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $productionEnvironment->uuid, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index bd1388806..ed15ab258 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Advanced extends Component { + use AuthorizesRequests; + public Application $application; #[Validate(['boolean'])] @@ -19,9 +22,15 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitLfsEnabled = false; + #[Validate(['boolean'])] + public bool $isGitShallowCloneEnabled = false; + #[Validate(['boolean'])] public bool $isPreviewDeploymentsEnabled = false; + #[Validate(['boolean'])] + public bool $isPrDeploymentsPublicEnabled = false; + #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -83,7 +92,9 @@ public function syncData(bool $toModel = false) $this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled; $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; + $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -108,7 +119,9 @@ public function syncData(bool $toModel = false) $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; + $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; @@ -137,6 +150,7 @@ private function resetDefaultLabels() public function instantSave() { try { + $this->authorize('update', $this->application); $reset = false; if ($this->isLogDrainEnabled) { if (! $this->application->destination->server->isLogDrainEnabled()) { @@ -175,6 +189,7 @@ public function instantSave() public function submit() { try { + $this->authorize('update', $this->application); if ($this->gpuCount && $this->gpuDeviceIds) { $this->dispatch('error', 'You cannot set both GPU count and GPU device IDs.'); $this->gpuCount = null; @@ -192,33 +207,39 @@ public function submit() public function saveCustomName() { - if (str($this->customInternalName)->isNotEmpty()) { - $this->customInternalName = str($this->customInternalName)->slug()->value(); - } else { - $this->customInternalName = null; - } - if (is_null($this->customInternalName)) { + try { + $this->authorize('update', $this->application); + + if (str($this->customInternalName)->isNotEmpty()) { + $this->customInternalName = str($this->customInternalName)->slug()->value(); + } else { + $this->customInternalName = null; + } + if (is_null($this->customInternalName)) { + $this->syncData(true); + $this->dispatch('success', 'Custom name saved.'); + + return; + } + $customInternalName = $this->customInternalName; + $server = $this->application->destination->server; + $allApplications = $server->applications(); + + $foundSameInternalName = $allApplications->filter(function ($application) { + return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName; + }); + if ($foundSameInternalName->isNotEmpty()) { + $this->dispatch('error', 'This custom container name is already in use by another application on this server.'); + $this->customInternalName = $customInternalName; + $this->syncData(true); + + return; + } $this->syncData(true); $this->dispatch('success', 'Custom name saved.'); - - return; + } catch (\Throwable $e) { + return handleError($e, $this); } - $customInternalName = $this->customInternalName; - $server = $this->application->destination->server; - $allApplications = $server->applications(); - - $foundSameInternalName = $allApplications->filter(function ($application) { - return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName; - }); - if ($foundSameInternalName->isNotEmpty()) { - $this->dispatch('error', 'This custom container name is already in use by another application on this server.'); - $this->customInternalName = $customInternalName; - $this->syncData(true); - - return; - } - $this->syncData(true); - $this->dispatch('success', 'Custom name saved.'); } public function render() diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index f0b95a9c8..5d7f3fd31 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -23,7 +23,9 @@ public function getListeners() return [ "echo-private:team.{$teamId},ServiceChecked" => '$refresh', + "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', ]; } @@ -48,6 +50,11 @@ public function mount() $this->project = $project; $this->environment = $environment; $this->application = $application; + + if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') { + return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); + } + if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); } diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index c957615ac..5b621cb95 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -18,11 +18,13 @@ class Index extends Component public int $skip = 0; - public int $default_take = 10; + public int $defaultTake = 10; - public bool $show_next = false; + public bool $showNext = false; - public bool $show_prev = false; + public bool $showPrev = false; + + public int $currentPage = 1; public ?string $pull_request_id = null; @@ -51,68 +53,111 @@ public function mount() if (! $application) { return redirect()->route('dashboard'); } - ['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->default_take); + // Validate pull request ID from URL parameters + if ($this->pull_request_id !== null && $this->pull_request_id !== '') { + if (! is_numeric($this->pull_request_id) || (float) $this->pull_request_id <= 0 || (float) $this->pull_request_id != (int) $this->pull_request_id) { + $this->pull_request_id = null; + $this->dispatch('error', 'Invalid Pull Request ID in URL. Filter cleared.'); + } else { + // Ensure it's stored as a string representation of a positive integer + $this->pull_request_id = (string) (int) $this->pull_request_id; + } + } + + ['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id); $this->application = $application; $this->deployments = $deployments; $this->deployments_count = $count; $this->current_url = url()->current(); - $this->show_pull_request_only(); - $this->show_more(); + $this->updateCurrentPage(); + $this->showMore(); } - private function show_pull_request_only() - { - if ($this->pull_request_id) { - $this->deployments = $this->deployments->where('pull_request_id', $this->pull_request_id); - } - } - - private function show_more() + private function showMore() { if ($this->deployments->count() !== 0) { - $this->show_next = true; - if ($this->deployments->count() < $this->default_take) { - $this->show_next = false; + $this->showNext = true; + if ($this->deployments->count() < $this->defaultTake) { + $this->showNext = false; } return; } } - public function reload_deployments() + public function reloadDeployments() { - $this->load_deployments(); + $this->loadDeployments(); } - public function previous_page(?int $take = null) + public function previousPage(?int $take = null) { if ($take) { $this->skip = $this->skip - $take; } - $this->skip = $this->skip - $this->default_take; + $this->skip = $this->skip - $this->defaultTake; if ($this->skip < 0) { - $this->show_prev = false; + $this->showPrev = false; $this->skip = 0; } - $this->load_deployments(); + $this->updateCurrentPage(); + $this->loadDeployments(); } - public function next_page(?int $take = null) + public function nextPage(?int $take = null) { if ($take) { $this->skip = $this->skip + $take; } - $this->show_prev = true; - $this->load_deployments(); + $this->showPrev = true; + $this->updateCurrentPage(); + $this->loadDeployments(); } - public function load_deployments() + public function loadDeployments() { - ['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->default_take); + ['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id); $this->deployments = $deployments; $this->deployments_count = $count; - $this->show_pull_request_only(); - $this->show_more(); + $this->showMore(); + } + + public function updatedPullRequestId($value) + { + // Sanitize and validate the pull request ID + if ($value !== null && $value !== '') { + // Check if it's numeric and positive + if (! is_numeric($value) || (float) $value <= 0 || (float) $value != (int) $value) { + $this->pull_request_id = null; + $this->dispatch('error', 'Invalid Pull Request ID. Please enter a valid positive number.'); + + return; + } + // Ensure it's stored as a string representation of a positive integer + $this->pull_request_id = (string) (int) $value; + } else { + $this->pull_request_id = null; + } + + // Reset pagination when filter changes + $this->skip = 0; + $this->showPrev = false; + $this->updateCurrentPage(); + $this->loadDeployments(); + } + + public function clearFilter() + { + $this->pull_request_id = null; + $this->skip = 0; + $this->showPrev = false; + $this->updateCurrentPage(); + $this->loadDeployments(); + } + + private function updateCurrentPage() + { + $this->currentPage = intval($this->skip / $this->defaultTake) + 1; } public function render() diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 66f387fcf..ebdc014ae 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -50,17 +50,48 @@ public function force_start() } } + public function copyLogsToClipboard(): string + { + $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); + + if (! $logs) { + return ''; + } + + $markdown = "# Deployment Logs\n\n"; + $markdown .= "```\n"; + + foreach ($logs as $log) { + if (isset($log['output'])) { + $markdown .= $log['output']."\n"; + } + } + + $markdown .= "```\n"; + + return $markdown; + } + public function cancel() { - $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}"; + $deployment_uuid = $this->application_deployment_queue->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + + // First, mark the deployment as cancelled to prevent further processing + $this->application_deployment_queue->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); } else { $server = Server::ownedByCurrentTeam()->find($server_id); } + + // Add cancellation log entry if ($this->application_deployment_queue->logs) { $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); @@ -77,13 +108,35 @@ public function cancel() 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), ]); } - instant_remote_process([$kill_command], $server); + + // Try to stop the helper container if it exists + // Check if container exists first + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + // Container exists, kill it + instant_remote_process([$kill_command], $server); + } else { + // Container hasn't started yet + $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); + } + + // Also try to kill any running process if we have a process ID + if ($this->application_deployment_queue->current_process_id) { + try { + $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone, that's ok + } + } } catch (\Throwable $e) { + // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { $this->application_deployment_queue->update([ 'current_process_id' => null, - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); next_after_cancel($server); } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 74f47232c..a733d8cb3 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -3,7 +3,10 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\GenerateConfig; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\Application; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; use Spatie\Url\Url; @@ -11,6 +14,9 @@ class General extends Component { + use AuthorizesRequests; + use SynchronizesModelData; + public string $applicationId; public Application $application; @@ -19,6 +25,8 @@ class General extends Component public string $name; + public ?string $description = null; + public ?string $fqdn = null; public string $git_repository; @@ -27,14 +35,82 @@ class General extends Component public ?string $git_commit_sha = null; + public ?string $install_command = null; + + public ?string $build_command = null; + + public ?string $start_command = null; + public string $build_pack; + public string $static_image; + + public string $base_directory; + + public ?string $publish_directory = null; + public ?string $ports_exposes = null; + public ?string $ports_mappings = null; + + public ?string $custom_network_aliases = null; + + public ?string $dockerfile = null; + + public ?string $dockerfile_location = null; + + public ?string $dockerfile_target_build = null; + + public ?string $docker_registry_image_name = null; + + public ?string $docker_registry_image_tag = null; + + public ?string $docker_compose_location = null; + + public ?string $docker_compose = null; + + public ?string $docker_compose_raw = null; + + public ?string $docker_compose_custom_start_command = null; + + public ?string $docker_compose_custom_build_command = null; + + public ?string $custom_labels = null; + + public ?string $custom_docker_run_options = null; + + public ?string $pre_deployment_command = null; + + public ?string $pre_deployment_command_container = null; + + public ?string $post_deployment_command = null; + + public ?string $post_deployment_command_container = null; + + public ?string $custom_nginx_configuration = null; + + public bool $is_static = false; + + public bool $is_spa = false; + + public bool $is_build_server_enabled = false; + public bool $is_preserve_repository_enabled = false; public bool $is_container_label_escape_enabled = true; + public bool $is_container_label_readonly_enabled = false; + + public bool $is_http_basic_auth_enabled = false; + + public ?string $http_basic_auth_username = null; + + public ?string $http_basic_auth_password = null; + + public ?string $watch_paths = null; + + public string $redirect; + public $customLabels; public bool $labelsChanged = false; @@ -47,57 +123,101 @@ class General extends Component public $parsedServiceDomains = []; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $listeners = [ 'resetDefaultLabels', 'configurationChanged' => '$refresh', + 'confirmDomainUsage', ]; - protected $rules = [ - 'application.name' => 'required', - 'application.description' => 'nullable', - 'application.fqdn' => 'nullable', - 'application.git_repository' => 'required', - 'application.git_branch' => 'required', - 'application.git_commit_sha' => 'nullable', - 'application.install_command' => 'nullable', - 'application.build_command' => 'nullable', - 'application.start_command' => 'nullable', - 'application.build_pack' => 'required', - 'application.static_image' => 'required', - 'application.base_directory' => 'required', - 'application.publish_directory' => 'nullable', - 'application.ports_exposes' => 'required', - 'application.ports_mappings' => 'nullable', - 'application.custom_network_aliases' => 'nullable', - 'application.dockerfile' => 'nullable', - 'application.docker_registry_image_name' => 'nullable', - 'application.docker_registry_image_tag' => 'nullable', - 'application.dockerfile_location' => 'nullable', - 'application.docker_compose_location' => 'nullable', - 'application.docker_compose' => 'nullable', - 'application.docker_compose_raw' => 'nullable', - 'application.dockerfile_target_build' => 'nullable', - 'application.docker_compose_custom_start_command' => 'nullable', - 'application.docker_compose_custom_build_command' => 'nullable', - 'application.custom_labels' => 'nullable', - 'application.custom_docker_run_options' => 'nullable', - 'application.pre_deployment_command' => 'nullable', - 'application.pre_deployment_command_container' => 'nullable', - 'application.post_deployment_command' => 'nullable', - 'application.post_deployment_command_container' => 'nullable', - 'application.custom_nginx_configuration' => 'nullable', - 'application.settings.is_static' => 'boolean|required', - 'application.settings.is_spa' => 'boolean|required', - 'application.settings.is_build_server_enabled' => 'boolean|required', - 'application.settings.is_container_label_escape_enabled' => 'boolean|required', - 'application.settings.is_container_label_readonly_enabled' => 'boolean|required', - 'application.settings.is_preserve_repository_enabled' => 'boolean|required', - 'application.is_http_basic_auth_enabled' => 'boolean|required', - 'application.http_basic_auth_username' => 'string|nullable', - 'application.http_basic_auth_password' => 'string|nullable', - 'application.watch_paths' => 'nullable', - 'application.redirect' => 'string|required', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'fqdn' => 'nullable', + 'git_repository' => 'required', + 'git_branch' => 'required', + 'git_commit_sha' => 'nullable', + 'install_command' => 'nullable', + 'build_command' => 'nullable', + 'start_command' => 'nullable', + 'build_pack' => 'required', + 'static_image' => 'required', + 'base_directory' => 'required', + 'publish_directory' => 'nullable', + 'ports_exposes' => 'required', + 'ports_mappings' => 'nullable', + 'custom_network_aliases' => 'nullable', + 'dockerfile' => 'nullable', + 'docker_registry_image_name' => 'nullable', + 'docker_registry_image_tag' => 'nullable', + 'dockerfile_location' => 'nullable', + 'docker_compose_location' => 'nullable', + 'docker_compose' => 'nullable', + 'docker_compose_raw' => 'nullable', + 'dockerfile_target_build' => 'nullable', + 'docker_compose_custom_start_command' => 'nullable', + 'docker_compose_custom_build_command' => 'nullable', + 'custom_labels' => 'nullable', + 'custom_docker_run_options' => 'nullable', + 'pre_deployment_command' => 'nullable', + 'pre_deployment_command_container' => 'nullable', + 'post_deployment_command' => 'nullable', + 'post_deployment_command_container' => 'nullable', + 'custom_nginx_configuration' => 'nullable', + 'is_static' => 'boolean|required', + 'is_spa' => 'boolean|required', + 'is_build_server_enabled' => 'boolean|required', + 'is_container_label_escape_enabled' => 'boolean|required', + 'is_container_label_readonly_enabled' => 'boolean|required', + 'is_preserve_repository_enabled' => 'boolean|required', + 'is_http_basic_auth_enabled' => 'boolean|required', + 'http_basic_auth_username' => 'string|nullable', + 'http_basic_auth_password' => 'string|nullable', + 'watch_paths' => 'nullable', + 'redirect' => 'string|required', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'git_repository.required' => 'The Git Repository field is required.', + 'git_branch.required' => 'The Git Branch field is required.', + 'build_pack.required' => 'The Build Pack field is required.', + 'static_image.required' => 'The Static Image field is required.', + 'base_directory.required' => 'The Base Directory field is required.', + 'ports_exposes.required' => 'The Exposed Ports field is required.', + 'is_static.required' => 'The Static setting is required.', + 'is_static.boolean' => 'The Static setting must be true or false.', + 'is_spa.required' => 'The SPA setting is required.', + 'is_spa.boolean' => 'The SPA setting must be true or false.', + 'is_build_server_enabled.required' => 'The Build Server setting is required.', + 'is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.', + 'is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.', + 'is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.', + 'is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.', + 'is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.', + 'is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.', + 'is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.', + 'is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.', + 'is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.', + 'redirect.required' => 'The Redirect setting is required.', + 'redirect.string' => 'The Redirect setting must be a string.', + ] + ); + } protected $validationAttributes = [ 'application.name' => 'name', @@ -145,86 +265,189 @@ public function mount() $this->parsedServices = $this->application->parse(); if (is_null($this->parsedServices) || empty($this->parsedServices)) { $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); + // Still sync data even if parse fails, so form fields are populated + $this->syncFromModel(); return; } } catch (\Throwable $e) { $this->dispatch('error', $e->getMessage()); + // Still sync data even on error, so form fields are populated + $this->syncFromModel(); } if ($this->application->build_pack === 'dockercompose') { - $this->application->fqdn = null; - $this->application->settings->save(); + // Only update if user has permission + try { + $this->authorize('update', $this->application); + $this->application->fqdn = null; + $this->application->settings->save(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, just continue without saving + } } $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; - $this->ports_exposes = $this->application->ports_exposes; - $this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled; - $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; + // Convert service names with dots and dashes to use underscores for HTML form binding + $sanitizedDomains = []; + foreach ($this->parsedServiceDomains as $serviceName => $domain) { + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); + $sanitizedDomains[$sanitizedKey] = $domain; + } + $this->parsedServiceDomains = $sanitizedDomains; + $this->customLabels = $this->application->parseContainerLabels(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) { - $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); - $this->application->custom_labels = base64_encode($this->customLabels); - $this->application->save(); + // Only update custom labels if user has permission + try { + $this->authorize('update', $this->application); + $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); + $this->application->custom_labels = base64_encode($this->customLabels); + $this->application->save(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, just use existing labels + // $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); + } } $this->initialDockerComposeLocation = $this->application->docker_compose_location; if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) { - $this->initLoadingCompose = true; - $this->dispatch('info', 'Loading docker compose file.'); + // Only load compose file if user has update permission + try { + $this->authorize('update', $this->application); + $this->initLoadingCompose = true; + $this->dispatch('info', 'Loading docker compose file.'); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, skip loading compose file + } } if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { $this->dispatch('configurationChanged'); } + + // Sync data from model to properties at the END, after all business logic + // This ensures any modifications to $this->application during mount() are reflected in properties + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'name' => 'application.name', + 'description' => 'application.description', + 'fqdn' => 'application.fqdn', + 'git_repository' => 'application.git_repository', + 'git_branch' => 'application.git_branch', + 'git_commit_sha' => 'application.git_commit_sha', + 'install_command' => 'application.install_command', + 'build_command' => 'application.build_command', + 'start_command' => 'application.start_command', + 'build_pack' => 'application.build_pack', + 'static_image' => 'application.static_image', + 'base_directory' => 'application.base_directory', + 'publish_directory' => 'application.publish_directory', + 'ports_exposes' => 'application.ports_exposes', + 'ports_mappings' => 'application.ports_mappings', + 'custom_network_aliases' => 'application.custom_network_aliases', + 'dockerfile' => 'application.dockerfile', + 'dockerfile_location' => 'application.dockerfile_location', + 'dockerfile_target_build' => 'application.dockerfile_target_build', + 'docker_registry_image_name' => 'application.docker_registry_image_name', + 'docker_registry_image_tag' => 'application.docker_registry_image_tag', + 'docker_compose_location' => 'application.docker_compose_location', + 'docker_compose' => 'application.docker_compose', + 'docker_compose_raw' => 'application.docker_compose_raw', + 'docker_compose_custom_start_command' => 'application.docker_compose_custom_start_command', + 'docker_compose_custom_build_command' => 'application.docker_compose_custom_build_command', + 'custom_labels' => 'application.custom_labels', + 'custom_docker_run_options' => 'application.custom_docker_run_options', + 'pre_deployment_command' => 'application.pre_deployment_command', + 'pre_deployment_command_container' => 'application.pre_deployment_command_container', + 'post_deployment_command' => 'application.post_deployment_command', + 'post_deployment_command_container' => 'application.post_deployment_command_container', + 'custom_nginx_configuration' => 'application.custom_nginx_configuration', + 'is_static' => 'application.settings.is_static', + 'is_spa' => 'application.settings.is_spa', + 'is_build_server_enabled' => 'application.settings.is_build_server_enabled', + 'is_preserve_repository_enabled' => 'application.settings.is_preserve_repository_enabled', + 'is_container_label_escape_enabled' => 'application.settings.is_container_label_escape_enabled', + 'is_container_label_readonly_enabled' => 'application.settings.is_container_label_readonly_enabled', + 'is_http_basic_auth_enabled' => 'application.is_http_basic_auth_enabled', + 'http_basic_auth_username' => 'application.http_basic_auth_username', + 'http_basic_auth_password' => 'application.http_basic_auth_password', + 'watch_paths' => 'application.watch_paths', + 'redirect' => 'application.redirect', + ]; } public function instantSave() { - if ($this->application->settings->isDirty('is_spa')) { - $this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static'); - } - if ($this->application->isDirty('is_http_basic_auth_enabled')) { - $this->application->save(); - } - $this->application->settings->save(); - $this->dispatch('success', 'Settings saved.'); - $this->application->refresh(); + try { + $this->authorize('update', $this->application); - // If port_exposes changed, reset default labels - if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { - $this->resetDefaultLabels(false); - } - if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) { - if ($this->application->settings->is_preserve_repository_enabled === false) { - $this->application->fileStorages->each(function ($storage) { - $storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled; - $storage->save(); - }); + $oldPortsExposes = $this->application->ports_exposes; + $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; + $oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled; + + $this->syncToModel(); + + if ($this->application->settings->isDirty('is_spa')) { + $this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static'); } - } - if ($this->application->settings->is_container_label_readonly_enabled) { - $this->resetDefaultLabels(false); - } + if ($this->application->isDirty('is_http_basic_auth_enabled')) { + $this->application->save(); + } + $this->application->settings->save(); + $this->dispatch('success', 'Settings saved.'); + $this->application->refresh(); + $this->syncFromModel(); + // If port_exposes changed, reset default labels + if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) { + $this->resetDefaultLabels(false); + } + if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) { + if ($this->is_preserve_repository_enabled === false) { + $this->application->fileStorages->each(function ($storage) { + $storage->is_based_on_git = $this->is_preserve_repository_enabled; + $storage->save(); + }); + } + } + if ($this->is_container_label_readonly_enabled) { + $this->resetDefaultLabels(false); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } } - public function loadComposeFile($isInit = false) + public function loadComposeFile($isInit = false, $showToast = true) { try { + $this->authorize('update', $this->application); + if ($isInit && $this->application->docker_compose_raw) { return; } - // Must reload the application to get the latest database changes - // Why? Not sure, but it works. - // $this->application->refresh(); - ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit); if (is_null($this->parsedServices)) { - $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); + $showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); return; } - $this->application->parse(); - $this->dispatch('success', 'Docker compose file loaded.'); + + // Refresh parsedServiceDomains to reflect any changes in docker_compose_domains + $this->application->refresh(); + $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; + // Convert service names with dots and dashes to use underscores for HTML form binding + $sanitizedDomains = []; + foreach ($this->parsedServiceDomains as $serviceName => $domain) { + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); + $sanitizedDomains[$sanitizedKey] = $domain; + } + $this->parsedServiceDomains = $sanitizedDomains; + + $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); $this->dispatch('refreshStorages'); $this->dispatch('refreshEnvs'); @@ -240,48 +463,108 @@ public function loadComposeFile($isInit = false) public function generateDomain(string $serviceName) { - $uuid = new Cuid2; - $domain = generateFqdn($this->application->destination->server, $uuid); - $this->parsedServiceDomains[$serviceName]['domain'] = $domain; - $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); - $this->application->save(); - $this->dispatch('success', 'Domain generated.'); - if ($this->application->build_pack === 'dockercompose') { - $this->loadComposeFile(); - } + try { + $this->authorize('update', $this->application); - return $domain; + $uuid = new Cuid2; + $domain = generateUrl(server: $this->application->destination->server, random: $uuid); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); + $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; + + // Convert back to original service names for storage + $originalDomains = []; + foreach ($this->parsedServiceDomains as $key => $value) { + // Find the original service name by checking parsed services + $originalServiceName = $key; + if (isset($this->parsedServices['services'])) { + foreach ($this->parsedServices['services'] as $originalName => $service) { + if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) { + $originalServiceName = $originalName; + break; + } + } + } + $originalDomains[$originalServiceName] = $value; + } + + $this->application->docker_compose_domains = json_encode($originalDomains); + $this->application->save(); + $this->dispatch('success', 'Domain generated.'); + if ($this->application->build_pack === 'dockercompose') { + $this->loadComposeFile(showToast: false); + } + + return $domain; + } catch (\Throwable $e) { + return handleError($e, $this); + } } - public function updatedApplicationBaseDirectory() + public function updatedBaseDirectory() { - if ($this->application->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose') { $this->loadComposeFile(); } } - public function updatedApplicationSettingsIsStatic($value) + public function updatedIsStatic($value) { if ($value) { $this->generateNginxConfiguration(); } } - public function updatedApplicationBuildPack() + public function updatedBuildPack() { - if ($this->application->build_pack !== 'nixpacks') { + // Check if user has permission to update + try { + $this->authorize('update', $this->application); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have permission, revert the change and return + $this->application->refresh(); + $this->syncFromModel(); + + return; + } + + // Sync property to model before checking/modifying + $this->syncToModel(); + + if ($this->build_pack !== 'nixpacks') { + $this->is_static = false; $this->application->settings->is_static = false; $this->application->settings->save(); } else { - $this->application->ports_exposes = $this->ports_exposes = 3000; + $this->ports_exposes = 3000; + $this->application->ports_exposes = 3000; $this->resetDefaultLabels(false); } - if ($this->application->build_pack === 'dockercompose') { - $this->application->fqdn = null; - $this->application->settings->save(); + if ($this->build_pack === 'dockercompose') { + // Only update if user has permission + try { + $this->authorize('update', $this->application); + $this->fqdn = null; + $this->application->fqdn = null; + $this->application->settings->save(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, just continue without saving + } + } else { + // Clear Docker Compose specific data when switching away from dockercompose + if ($this->application->getOriginal('build_pack') === 'dockercompose') { + $this->application->docker_compose_domains = null; + $this->application->docker_compose_raw = null; + + // Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables + $this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete(); + $this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete(); + $this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete(); + $this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete(); + } } - if ($this->application->build_pack === 'static') { - $this->application->ports_exposes = $this->ports_exposes = 80; + if ($this->build_pack === 'static') { + $this->ports_exposes = 80; + $this->application->ports_exposes = 80; $this->resetDefaultLabels(false); $this->generateNginxConfiguration(); } @@ -291,36 +574,55 @@ public function updatedApplicationBuildPack() public function getWildcardDomain() { - $server = data_get($this->application, 'destination.server'); - if ($server) { - $fqdn = generateFqdn($server, $this->application->uuid); - $this->application->fqdn = $fqdn; - $this->application->save(); - $this->resetDefaultLabels(); - $this->dispatch('success', 'Wildcard domain generated.'); + try { + $this->authorize('update', $this->application); + + $server = data_get($this->application, 'destination.server'); + if ($server) { + $fqdn = generateUrl(server: $server, random: $this->application->uuid); + $this->fqdn = $fqdn; + $this->syncToModel(); + $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); + $this->resetDefaultLabels(); + $this->dispatch('success', 'Wildcard domain generated.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); } } public function generateNginxConfiguration($type = 'static') { - $this->application->custom_nginx_configuration = defaultNginxConfiguration($type); - $this->application->save(); - $this->dispatch('success', 'Nginx configuration generated.'); + try { + $this->authorize('update', $this->application); + + $this->custom_nginx_configuration = defaultNginxConfiguration($type); + $this->syncToModel(); + $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); + $this->dispatch('success', 'Nginx configuration generated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function resetDefaultLabels($manualReset = false) { try { - if (! $this->application->settings->is_container_label_readonly_enabled && ! $manualReset) { + if (! $this->is_container_label_readonly_enabled && ! $manualReset) { return; } $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); - $this->ports_exposes = $this->application->ports_exposes; - $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; - $this->application->custom_labels = base64_encode($this->customLabels); + $this->custom_labels = base64_encode($this->customLabels); + $this->syncToModel(); $this->application->save(); - if ($this->application->build_pack === 'dockercompose') { - $this->loadComposeFile(); + $this->application->refresh(); + $this->syncFromModel(); + if ($this->build_pack === 'dockercompose') { + $this->loadComposeFile(showToast: false); } $this->dispatch('configurationChanged'); } catch (\Throwable $e) { @@ -330,23 +632,49 @@ public function resetDefaultLabels($manualReset = false) public function checkFqdns($showToaster = true) { - if (data_get($this->application, 'fqdn')) { - $domains = str($this->application->fqdn)->trim()->explode(','); + if ($this->fqdn) { + $domains = str($this->fqdn)->trim()->explode(','); if ($this->application->additional_servers->count() === 0) { foreach ($domains as $domain) { - if (! validate_dns_entry($domain, $this->application->destination->server)) { + if (! validateDNSEntry($domain, $this->application->destination->server)) { $showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

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

Check this documentation for further help."); } } } - check_domain_usage(resource: $this->application); - $this->application->fqdn = $domains->implode(','); + + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return false; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + + $this->fqdn = $domains->implode(','); + $this->application->fqdn = $this->fqdn; $this->resetDefaultLabels(false); } + + return true; + } + + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); } public function setRedirect() { + $this->authorize('update', $this->application); + try { $has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count(); if ($has_www === 0 && $this->application->redirect === 'www') { @@ -365,20 +693,31 @@ public function setRedirect() public function submit($showToaster = true) { try { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $this->authorize('update', $this->application); + + $this->validate(); + + $oldPortsExposes = $this->application->ports_exposes; + $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; + $oldDockerComposeLocation = $this->initialDockerComposeLocation; + + // Process FQDN with intermediate variable to avoid Collection/string confusion + $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); + $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); + $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); - $this->application->fqdn = $this->application->fqdn->unique()->implode(','); - $warning = sslipDomainWarning($this->application->fqdn); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - // $this->resetDefaultLabels(); + + $this->syncToModel(); if ($this->application->isDirty('redirect')) { $this->setRedirect(); @@ -387,7 +726,9 @@ public function submit($showToaster = true) $this->application->parseHealthcheckFromDockerfile($this->application->dockerfile); } - $this->checkFqdns(); + if (! $this->checkFqdns()) { + return; // Stop if there are conflicts and user hasn't confirmed + } $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { @@ -396,62 +737,78 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) { - $compose_return = $this->loadComposeFile(); + if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) { + $compose_return = $this->loadComposeFile(showToast: false); if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { return; } } - $this->validate(); - if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { + if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) { $this->resetDefaultLabels(); } - if (data_get($this->application, 'build_pack') === 'dockerimage') { + if ($this->build_pack === 'dockerimage') { $this->validate([ - 'application.docker_registry_image_name' => 'required', + 'docker_registry_image_name' => 'required', ]); } - if (data_get($this->application, 'custom_docker_run_options')) { - $this->application->custom_docker_run_options = str($this->application->custom_docker_run_options)->trim(); + if ($this->custom_docker_run_options) { + $this->custom_docker_run_options = str($this->custom_docker_run_options)->trim()->toString(); + $this->application->custom_docker_run_options = $this->custom_docker_run_options; } - if (data_get($this->application, 'dockerfile')) { - $port = get_port_from_dockerfile($this->application->dockerfile); - if ($port && ! $this->application->ports_exposes) { + if ($this->dockerfile) { + $port = get_port_from_dockerfile($this->dockerfile); + if ($port && ! $this->ports_exposes) { + $this->ports_exposes = $port; $this->application->ports_exposes = $port; } } - if ($this->application->base_directory && $this->application->base_directory !== '/') { - $this->application->base_directory = rtrim($this->application->base_directory, '/'); + if ($this->base_directory && $this->base_directory !== '/') { + $this->base_directory = rtrim($this->base_directory, '/'); + $this->application->base_directory = $this->base_directory; } - if ($this->application->publish_directory && $this->application->publish_directory !== '/') { - $this->application->publish_directory = rtrim($this->application->publish_directory, '/'); + if ($this->publish_directory && $this->publish_directory !== '/') { + $this->publish_directory = rtrim($this->publish_directory, '/'); + $this->application->publish_directory = $this->publish_directory; } - if ($this->application->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose') { $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); - - foreach ($this->parsedServiceDomains as $serviceName => $service) { - $domain = data_get($service, 'domain'); - if ($domain) { - if (! validate_dns_entry($domain, $this->application->destination->server)) { - $showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

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

Check this documentation for further help."); - } - check_domain_usage(resource: $this->application); - } - } if ($this->application->isDirty('docker_compose_domains')) { + foreach ($this->parsedServiceDomains as $service) { + $domain = data_get($service, 'domain'); + if ($domain) { + if (! validateDNSEntry($domain, $this->application->destination->server)) { + $showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

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

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

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

Check this documentation for further help."); - $success = false; - } - check_domain_usage(resource: $this->application, domain: $preview->fqdn); - } if (! $preview) { throw new \Exception('Preview not found'); } - $success && $preview->save(); - $success && $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); + + // Find the key for this preview in the collection + $previewKey = $this->application->previews->search(function ($item) use ($preview_id) { + return $item->id == $preview_id; + }); + + if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { + $fqdn = $this->previewFqdns[$previewKey]; + + if (! empty($fqdn)) { + $fqdn = str($fqdn)->replaceEnd(',', '')->trim(); + $fqdn = str($fqdn)->replaceStart(',', '')->trim(); + $fqdn = str($fqdn)->trim()->lower(); + $this->previewFqdns[$previewKey] = $fqdn; + + if (! validateDNSEntry($fqdn, $this->application->destination->server)) { + $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

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

Check this documentation for further help."); + $success = false; + } + + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application, domain: $fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + $this->pendingPreviewId = $preview_id; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + } + } + + if ($success) { + $this->syncData(true); + $preview->save(); + $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -73,38 +146,38 @@ public function save_preview($preview_id) public function generate_preview($preview_id) { - $preview = $this->application->previews->find($preview_id); - if (! $preview) { - $this->dispatch('error', 'Preview not found.'); + try { + $this->authorize('update', $this->application); - return; - } - if ($this->application->build_pack === 'dockercompose') { - $preview->generate_preview_fqdn_compose(); + $preview = $this->application->previews->find($preview_id); + if (! $preview) { + $this->dispatch('error', 'Preview not found.'); + + return; + } + if ($this->application->build_pack === 'dockercompose') { + $preview->generate_preview_fqdn_compose(); + $this->application->refresh(); + $this->syncData(false); + $this->dispatch('success', 'Domain generated.'); + + return; + } + + $preview->generate_preview_fqdn(); $this->application->refresh(); + $this->syncData(false); + $this->dispatch('update_links'); $this->dispatch('success', 'Domain generated.'); - - return; + } catch (\Throwable $e) { + return handleError($e, $this); } - - $fqdn = generateFqdn($this->application->destination->server, $this->application->uuid); - $url = Url::fromString($fqdn); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $preview->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - $this->dispatch('success', 'Domain generated.'); } public function add(int $pull_request_id, ?string $pull_request_html_url = null) { try { + $this->authorize('update', $this->application); if ($this->application->build_pack === 'dockercompose') { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -118,6 +191,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } $found->generate_preview_fqdn_compose(); $this->application->refresh(); + $this->syncData(false); } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -128,8 +202,9 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) 'pull_request_html_url' => $pull_request_html_url, ]); } - $this->application->generate_preview_fqdn($pull_request_id); + $found->generate_preview_fqdn(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } @@ -138,14 +213,25 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } } + public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null) + { + $this->authorize('deploy', $this->application); + + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + } + public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) { + $this->authorize('deploy', $this->application); + $this->add($pull_request_id, $pull_request_html_url); $this->deploy($pull_request_id, $pull_request_html_url); } - public function deploy(int $pull_request_id, ?string $pull_request_html_url = null) + public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false) { + $this->authorize('deploy', $this->application); + try { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -159,7 +245,7 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu $result = queue_application_deployment( application: $this->application, deployment_uuid: $this->deployment_uuid, - force_rebuild: false, + force_rebuild: $force_rebuild, pull_request_id: $pull_request_id, git_type: $found->git_type ?? null, ); @@ -186,8 +272,22 @@ protected function setDeploymentUuid() $this->parameters['deployment_uuid'] = $this->deployment_uuid; } + private function stopContainers(array $containers, $server) + { + $containersToStop = collect($containers)->pluck('Names')->toArray(); + + foreach ($containersToStop as $containerName) { + instant_remote_process(command: [ + "docker stop --time=30 $containerName", + "docker rm -f $containerName", + ], server: $server, throwError: false); + } + } + public function stop(int $pull_request_id) { + $this->authorize('deploy', $this->application); + try { $server = $this->application->destination->server; @@ -210,36 +310,29 @@ public function stop(int $pull_request_id) public function delete(int $pull_request_id) { try { - $server = $this->application->destination->server; + $this->authorize('delete', $this->application); + $preview = ApplicationPreview::where('application_id', $this->application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); - if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); - } else { - $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); - $this->stopContainers($containers, $server); + if (! $preview) { + $this->dispatch('error', 'Preview not found.'); + + return; } - ApplicationPreview::where('application_id', $this->application->id) - ->where('pull_request_id', $pull_request_id) - ->first() - ->delete(); + // Soft delete immediately for instant UI feedback + $preview->delete(); - $this->application->refresh(); + // Dispatch the job for async cleanup (container stopping + force delete) + DeleteResourceJob::dispatch($preview); + + // Refresh the application and its previews relationship to reflect the soft delete + $this->application->load('previews'); $this->dispatch('update_links'); - $this->dispatch('success', 'Preview deleted.'); + $this->dispatch('success', 'Preview deletion started. It may take a few moments to complete.'); } catch (\Throwable $e) { return handleError($e, $this); } } - - private function stopContainers(array $containers, $server, int $timeout = 30) - { - foreach ($containers as $container) { - $containerName = str_replace('/', '', $container['Names']); - instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", - "docker rm -f $containerName", - ], server: $server, throwError: false); - } - } } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index b3e838bb3..942dfeb37 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -3,18 +3,28 @@ namespace App\Livewire\Project\Application; use App\Models\ApplicationPreview; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; class PreviewsCompose extends Component { + use AuthorizesRequests; + public $service; public $serviceName; public ApplicationPreview $preview; + public ?string $domain = null; + + public function mount() + { + $this->domain = data_get($this->service, 'domain'); + } + public function render() { return view('livewire.project.application.previews-compose'); @@ -22,40 +32,90 @@ public function render() public function save() { - $domain = data_get($this->service, 'domain'); - $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $domain; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain saved.'); + try { + $this->authorize('update', $this->preview->application); + + $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); + $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; + $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? []; + $docker_compose_domains[$this->serviceName]['domain'] = $this->domain; + $this->preview->docker_compose_domains = json_encode($docker_compose_domains); + $this->preview->save(); + $this->dispatch('update_links'); + $this->dispatch('success', 'Domain saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function generate() { - $domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect(); - $domain = $domains->first(function ($_, $key) { - return $key === $this->serviceName; - }); - if ($domain) { - $domain = data_get($domain, 'domain'); - $url = Url::fromString($domain); - $template = $this->preview->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + try { + $this->authorize('update', $this->preview->application); + + $domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []); + $domain = $domains->first(function ($_, $key) { + return $key === $this->serviceName; + }); + + $domain_string = data_get($domain, 'domain'); + + // If no domain is set in the main application, generate a default domain + if (empty($domain_string)) { + $server = $this->preview->application->destination->server; + $template = $this->preview->application->preview_url_template; + $random = new Cuid2; + + // Generate a unique domain like main app services do + $generated_fqdn = generateUrl(server: $server, random: $random); + + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); + $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; + } else { + // Use the existing domain from the main application + // Handle multiple domains separated by commas + $domain_list = explode(',', $domain_string); + $preview_fqdns = []; + $template = $this->preview->application->preview_url_template; + $random = new Cuid2; + + foreach ($domain_list as $single_domain) { + $single_domain = trim($single_domain); + if (empty($single_domain)) { + continue; + } + + $url = Url::fromString($single_domain); + $host = $url->getHost(); + $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; + + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); + $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn); + $preview_fqdns[] = "$schema://$preview_fqdn"; + } + + $preview_fqdn = implode(',', $preview_fqdns); + } + + // Save the generated domain + $this->domain = $preview_fqdn; $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn; + $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; + $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? []; + $docker_compose_domains[$this->serviceName]['domain'] = $this->domain; $this->preview->docker_compose_domains = json_encode($docker_compose_domains); $this->preview->save(); + + $this->dispatch('update_links'); + $this->dispatch('success', 'Domain generated.'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); } } diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index ff5db1e08..da67a5707 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class Rollback extends Component { + use AuthorizesRequests; + public Application $application; public $images = []; @@ -23,6 +26,8 @@ public function mount() public function rollbackImage($commit) { + $this->authorize('deploy', $this->application); + $deployment_uuid = new Cuid2; queue_application_deployment( @@ -43,6 +48,8 @@ public function rollbackImage($commit) public function loadImages($showToast = false) { + $this->authorize('view', $this->application); + try { $image = $this->application->docker_registry_image_name ?? $this->application->uuid; if ($this->application->destination->server->isFunctional()) { diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index e27a550c1..ab2517f2b 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -4,12 +4,15 @@ use App\Models\Application; use App\Models\PrivateKey; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Source extends Component { + use AuthorizesRequests; + public Application $application; #[Locked] @@ -44,6 +47,21 @@ public function mount() } } + public function updatedGitRepository() + { + $this->gitRepository = trim($this->gitRepository); + } + + public function updatedGitBranch() + { + $this->gitBranch = trim($this->gitBranch); + } + + public function updatedGitCommitSha() + { + $this->gitCommitSha = trim($this->gitCommitSha); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -54,6 +72,9 @@ public function syncData(bool $toModel = false) 'git_commit_sha' => $this->gitCommitSha, 'private_key_id' => $this->privateKeyId, ]); + // Refresh to get the trimmed values from the model + $this->application->refresh(); + $this->syncData(false); } else { $this->gitRepository = $this->application->git_repository; $this->gitBranch = $this->application->git_branch; @@ -81,6 +102,7 @@ private function getSources() public function setPrivateKey(int $privateKeyId) { try { + $this->authorize('update', $this->application); $this->privateKeyId = $privateKeyId; $this->syncData(true); $this->getPrivateKeys(); @@ -94,7 +116,9 @@ public function setPrivateKey(int $privateKeyId) public function submit() { + try { + $this->authorize('update', $this->application); if (str($this->gitCommitSha)->isEmpty()) { $this->gitCommitSha = 'HEAD'; } @@ -107,12 +131,25 @@ public function submit() public function changeSource($sourceId, $sourceType) { + try { + $this->authorize('update', $this->application); $this->application->update([ 'source_id' => $sourceId, 'source_type' => $sourceType, - 'repository_project_id' => null, ]); + + ['repository' => $customRepository] = $this->application->customRepository(); + $repository = githubApi($this->application->source, "repos/{$customRepository}"); + $data = data_get($repository, 'data'); + $repository_project_id = data_get($data, 'id'); + if (isset($repository_project_id)) { + if ($this->application->repository_project_id !== $repository_project_id) { + $this->application->repository_project_id = $repository_project_id; + $this->application->save(); + } + } + $this->application->refresh(); $this->getSources(); $this->dispatch('success', 'Source updated!'); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index c71f6db64..3b3e42619 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project; -use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Service\StartService; @@ -11,6 +10,7 @@ use App\Models\Environment; use App\Models\Project; use App\Models\Server; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -42,11 +42,14 @@ class CloneMe extends Component public bool $cloneVolumeData = false; - protected $messages = [ - 'selectedServer' => 'Please select a server.', - 'selectedDestination' => 'Please select a server & destination.', - 'newName' => 'Please enter a name for the new project or environment.', - ]; + protected function messages(): array + { + return array_merge([ + 'selectedServer' => 'Please select a server.', + 'selectedDestination' => 'Please select a server & destination.', + 'newName.required' => 'Please enter a name for the new project or environment.', + ], ValidationPatterns::nameMessages()); + } public function mount($project_uuid) { @@ -54,7 +57,10 @@ public function mount($project_uuid) $this->project = Project::where('uuid', $project_uuid)->firstOrFail(); $this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first(); $this->project_id = $this->project->id; - $this->servers = currentTeam()->servers; + $this->servers = currentTeam() + ->servers() + ->get() + ->reject(fn ($server) => $server->isBuildServer()); $this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug(); } @@ -87,7 +93,7 @@ public function clone(string $type) try { $this->validate([ 'selectedDestination' => 'required', - 'newName' => 'required', + 'newName' => ValidationPatterns::nameRules(), ]); if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); @@ -121,144 +127,10 @@ public function clone(string $type) $databases = $this->environment->databases(); $services = $this->environment->services; foreach ($applications as $application) { - $applicationSettings = $application->settings; - - $uuid = (string) new Cuid2; - $url = $application->fqdn; - if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateFqdn($this->server, $uuid); - } - - $newApplication = $application->replicate([ - 'id', - 'created_at', - 'updated_at', - 'additional_servers_count', - 'additional_networks_count', - ])->fill([ - 'uuid' => $uuid, - 'fqdn' => $url, - 'status' => 'exited', + $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first(); + clone_application($application, $selectedDestination, [ 'environment_id' => $environment->id, - 'destination_id' => $this->selectedDestination, - ]); - $newApplication->save(); - - if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n"); - $newApplication->custom_labels = base64_encode($customLabels); - $newApplication->save(); - } - - $newApplication->settings()->delete(); - if ($applicationSettings) { - $newApplicationSettings = $applicationSettings->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $newApplication->id, - ]); - $newApplicationSettings->save(); - } - - $tags = $application->tags; - foreach ($tags as $tag) { - $newApplication->tags()->attach($tag->id); - } - - $scheduledTasks = $application->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'application_id' => $newApplication->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $applicationPreviews = $application->previews()->get(); - foreach ($applicationPreviews as $preview) { - $newPreview = $preview->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $newApplication->id, - 'status' => 'exited', - ]); - $newPreview->save(); - } - - $persistentVolumes = $application->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $application->uuid)) { - $newName = str($volume->name)->replace($application->uuid, $newApplication->uuid); - } else { - $newName = $newApplication->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $newApplication->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopApplication::dispatch($application, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->destination->server; - $targetServer = $newApplication->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: (string) new Cuid2, - application: $application, - server: $sourceServer, - destination: $application->destination, - no_questions_asked: true - ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $application->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $newApplication->id, - ]); - $newStorage->save(); - } - - $environmentVaribles = $application->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $newApplication->id, - ]); - $newEnvironmentVariable->save(); - } + ], $this->cloneVolumeData); } foreach ($databases as $database) { @@ -451,7 +323,7 @@ public function clone(string $type) if ($this->cloneVolumeData) { try { - StopService::dispatch($application, false, false); + StopService::dispatch($application); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $application->service->destination->server; @@ -505,7 +377,7 @@ public function clone(string $type) if ($this->cloneVolumeData) { try { - StopService::dispatch($database->service, false, false); + StopService::dispatch($database->service); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $database->service->destination->server; diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 0d363e983..7deaa82a9 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; @@ -14,6 +15,8 @@ class BackupEdit extends Component { + use AuthorizesRequests; + public ScheduledDatabaseBackup $backup; #[Locked] @@ -64,6 +67,9 @@ class BackupEdit extends Component #[Validate(['required', 'boolean'])] public bool $saveS3 = false; + #[Validate(['required', 'boolean'])] + public bool $disableLocalBackup = false; + #[Validate(['nullable', 'integer'])] public ?int $s3StorageId = 1; @@ -73,9 +79,13 @@ class BackupEdit extends Component #[Validate(['required', 'boolean'])] public bool $dumpAll = false; + #[Validate(['required', 'int', 'min:1', 'max:36000'])] + public int $timeout = 3600; + public function mount() { try { + $this->authorize('view', $this->backup->database); $this->parameters = get_route_parameters(); $this->syncData(); } catch (Exception $e) { @@ -95,9 +105,11 @@ public function syncData(bool $toModel = false) $this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3; $this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3; $this->backup->save_s3 = $this->saveS3; + $this->backup->disable_local_backup = $this->disableLocalBackup; $this->backup->s3_storage_id = $this->s3StorageId; $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; + $this->backup->timeout = $this->timeout; $this->customValidate(); $this->backup->save(); } else { @@ -111,14 +123,18 @@ public function syncData(bool $toModel = false) $this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3; $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; $this->saveS3 = $this->backup->save_s3; + $this->disableLocalBackup = $this->backup->disable_local_backup ?? false; $this->s3StorageId = $this->backup->s3_storage_id; $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; + $this->timeout = $this->backup->timeout; } } public function delete($password) { + $this->authorize('manageBackups', $this->backup->database); + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); @@ -176,6 +192,8 @@ public function delete($password) public function instantSave() { try { + $this->authorize('manageBackups', $this->backup->database); + $this->syncData(true); $this->dispatch('success', 'Backup updated successfully.'); } catch (\Throwable $e) { @@ -188,6 +206,12 @@ private function customValidate() if (! is_numeric($this->backup->s3_storage_id)) { $this->backup->s3_storage_id = null; } + + // Validate that disable_local_backup can only be true when S3 backup is enabled + if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { + $this->backup->disable_local_backup = $this->disableLocalBackup = false; + } + $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { throw new \Exception('Invalid Cron / Human expression'); @@ -198,6 +222,8 @@ private function customValidate() public function submit() { try { + $this->authorize('manageBackups', $this->backup->database); + $this->syncData(true); $this->dispatch('success', 'Backup updated successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 3fc721fda..0b6d8338b 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -4,6 +4,7 @@ use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; @@ -14,7 +15,19 @@ class BackupExecutions extends Component public $database; - public $executions = []; + public ?Collection $executions; + + public int $executions_count = 0; + + public int $skip = 0; + + public int $defaultTake = 10; + + public bool $showNext = false; + + public bool $showPrev = false; + + public int $currentPage = 1; public $setDeletableBackup; @@ -40,6 +53,20 @@ public function cleanupFailed() } } + 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.'); + } + } + } + public function deleteBackup($executionId, $password) { if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { @@ -85,18 +112,74 @@ public function download_file($exeuctionId) public function refreshBackupExecutions(): void { - if ($this->backup && $this->backup->exists) { - $this->executions = $this->backup->executions()->get()->toArray(); - } else { - $this->executions = []; + $this->loadExecutions(); + } + + public function reloadExecutions() + { + $this->loadExecutions(); + } + + public function previousPage(?int $take = null) + { + if ($take) { + $this->skip = $this->skip - $take; } + $this->skip = $this->skip - $this->defaultTake; + if ($this->skip < 0) { + $this->showPrev = false; + $this->skip = 0; + } + $this->updateCurrentPage(); + $this->loadExecutions(); + } + + public function nextPage(?int $take = null) + { + if ($take) { + $this->skip = $this->skip + $take; + } + $this->showPrev = true; + $this->updateCurrentPage(); + $this->loadExecutions(); + } + + private function loadExecutions() + { + if ($this->backup && $this->backup->exists) { + ['executions' => $executions, 'count' => $count] = $this->backup->executionsPaginated($this->skip, $this->defaultTake); + $this->executions = $executions; + $this->executions_count = $count; + } else { + $this->executions = collect([]); + $this->executions_count = 0; + } + $this->showMore(); + } + + private function showMore() + { + if ($this->executions->count() !== 0) { + $this->showNext = true; + if ($this->executions->count() < $this->defaultTake) { + $this->showNext = false; + } + + return; + } + } + + private function updateCurrentPage() + { + $this->currentPage = intval($this->skip / $this->defaultTake) + 1; } public function mount(ScheduledDatabaseBackup $backup) { $this->backup = $backup; $this->database = $backup->database; - $this->refreshBackupExecutions(); + $this->updateCurrentPage(); + $this->loadExecutions(); } public function server() @@ -119,11 +202,6 @@ public function server() public function render() { - return view('livewire.project.database.backup-executions', [ - 'checkboxes' => [ - ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], - // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], - ], - ]); + return view('livewire.project.database.backup-executions'); } } diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index 3cd360562..decd59a4c 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -3,14 +3,19 @@ namespace App\Livewire\Project\Database; use App\Jobs\DatabaseBackupJob; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class BackupNow extends Component { + use AuthorizesRequests; + public $backup; public function backupNow() { + $this->authorize('manageBackups', $this->backup->database); + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2d39c5151..c4a7983b8 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -6,51 +6,42 @@ use App\Actions\Database\StopDatabaseProxy; use App\Models\Server; use App\Models\StandaloneClickhouse; +use App\Support\ValidationPatterns; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Livewire\Attributes\Validate; use Livewire\Component; class General extends Component { - public Server $server; + use AuthorizesRequests; + + public ?Server $server = null; public StandaloneClickhouse $database; - #[Validate(['required', 'string'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; - #[Validate(['required', 'string'])] public string $clickhouseAdminUser; - #[Validate(['required', 'string'])] public string $clickhouseAdminPassword; - #[Validate(['required', 'string'])] public string $image; - #[Validate(['nullable', 'string'])] public ?string $portsMappings = null; - #[Validate(['nullable', 'boolean'])] public ?bool $isPublic = null; - #[Validate(['nullable', 'integer'])] public ?int $publicPort = null; - #[Validate(['nullable', 'string'])] public ?string $customDockerRunOptions = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrl = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrlPublic = null; - #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; public function getListeners() @@ -65,13 +56,53 @@ public function getListeners() public function mount() { try { + $this->authorize('view', $this->database); $this->syncData(); $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); + + return; + } } catch (\Throwable $e) { return handleError($e, $this); } } + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'clickhouseAdminUser' => 'required|string', + 'clickhouseAdminPassword' => 'required|string', + 'image' => 'required|string', + 'portsMappings' => 'nullable|string', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'customDockerRunOptions' => 'nullable|string', + 'dbUrl' => 'nullable|string', + 'dbUrlPublic' => 'nullable|string', + 'isLogDrainEnabled' => 'nullable|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'clickhouseAdminUser.required' => 'The Admin User field is required.', + 'clickhouseAdminUser.string' => 'The Admin User must be a string.', + 'clickhouseAdminPassword.required' => 'The Admin Password field is required.', + 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.', + 'image.required' => 'The Docker Image field is required.', + 'image.string' => 'The Docker Image must be a string.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -109,6 +140,8 @@ public function syncData(bool $toModel = false) public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -127,6 +160,8 @@ public function instantSaveAdvanced() public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; @@ -164,6 +199,8 @@ public function databaseProxyStopped() public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 6c4d0867e..7c64a6eef 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project\Database; use Auth; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Configuration extends Component { + use AuthorizesRequests; + public $currentRoute; public $database; @@ -26,27 +29,40 @@ public function getListeners() public function mount() { - $this->currentRoute = request()->route()->getName(); + try { + $this->currentRoute = request()->route()->getName(); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', request()->route('project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'name', 'project_id', 'uuid') - ->where('uuid', request()->route('environment_uuid')) - ->firstOrFail(); - $database = $environment->databases() - ->where('uuid', request()->route('database_uuid')) - ->firstOrFail(); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'name', 'project_id', 'uuid') + ->where('uuid', request()->route('environment_uuid')) + ->firstOrFail(); + $database = $environment->databases() + ->where('uuid', request()->route('database_uuid')) + ->firstOrFail(); - $this->database = $database; - $this->project = $project; - $this->environment = $environment; - if (str($this->database->status)->startsWith('running') && is_null($this->database->config_hash)) { - $this->database->isConfigurationChanged(true); - $this->dispatch('configurationChanged'); + $this->authorize('view', $database); + + $this->database = $database; + $this->project = $project; + $this->environment = $environment; + if (str($this->database->status)->startsWith('running') && is_null($this->database->config_hash)) { + $this->database->isConfigurationChanged(true); + $this->dispatch('configurationChanged'); + } + } catch (\Throwable $e) { + if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + return redirect()->route('dashboard'); + } + if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + return redirect()->route('dashboard'); + } + + return handleError($e, $this); } } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 01108c290..7f807afe2 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -10,6 +11,8 @@ class CreateScheduledBackup extends Component { + use AuthorizesRequests; + #[Validate(['required', 'string'])] public $frequency; @@ -41,6 +44,8 @@ public function mount() public function submit() { try { + $this->authorize('manageBackups', $this->database); + $this->validate(); $isValid = validate_cron_expression($this->frequency); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 0fffbef31..9052a4749 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -6,56 +6,46 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Livewire\Attributes\Validate; use Livewire\Component; class General extends Component { - public Server $server; + use AuthorizesRequests; + + public ?Server $server = null; public StandaloneDragonfly $database; - #[Validate(['required', 'string'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; - #[Validate(['required', 'string'])] public string $dragonflyPassword; - #[Validate(['required', 'string'])] public string $image; - #[Validate(['nullable', 'string'])] public ?string $portsMappings = null; - #[Validate(['nullable', 'boolean'])] public ?bool $isPublic = null; - #[Validate(['nullable', 'integer'])] public ?int $publicPort = null; - #[Validate(['nullable', 'string'])] public ?string $customDockerRunOptions = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrl = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrlPublic = null; - #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; public ?Carbon $certificateValidUntil = null; - #[Validate(['nullable', 'boolean'])] public bool $enable_ssl = false; public function getListeners() @@ -72,8 +62,14 @@ public function getListeners() public function mount() { try { + $this->authorize('view', $this->database); $this->syncData(); $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); + + return; + } $existingCert = $this->database->sslCertificates()->first(); @@ -85,6 +81,38 @@ public function mount() } } + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'dragonflyPassword' => 'required|string', + 'image' => 'required|string', + 'portsMappings' => 'nullable|string', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'customDockerRunOptions' => 'nullable|string', + 'dbUrl' => 'nullable|string', + 'dbUrlPublic' => 'nullable|string', + 'isLogDrainEnabled' => 'nullable|boolean', + 'enable_ssl' => 'nullable|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', + 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', + 'image.required' => 'The Docker Image field is required.', + 'image.string' => 'The Docker Image must be a string.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -122,6 +150,8 @@ public function syncData(bool $toModel = false) public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -140,6 +170,8 @@ public function instantSaveAdvanced() public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; @@ -177,6 +209,8 @@ public function databaseProxyStopped() public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -196,6 +230,8 @@ public function submit() public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -206,6 +242,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -216,13 +254,13 @@ public function regenerateSslCertificate() $server = $this->database->destination->server; - $caCert = SslCertificate::where('server_id', $server->id) + $caCert = $server->sslCertificates() ->where('is_ca_certificate', true) ->first(); if (! $caCert) { $server->generateCaCertificate(); - $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); } if (! $caCert) { diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index e11402844..6a287f8cc 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -7,10 +7,13 @@ use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; use App\Events\ServiceStatusChanged; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Heading extends Component { + use AuthorizesRequests; + public $database; public array $parameters; @@ -33,7 +36,10 @@ public function getListeners() public function activityFinished() { try { - $this->database->started_at ??= now(); + // Only set started_at if database is actually running + if ($this->database->isRunning()) { + $this->database->started_at ??= now(); + } $this->database->save(); if (is_null($this->database->config_hash) || $this->database->isConfigurationChanged()) { @@ -64,7 +70,9 @@ public function mount() public function stop() { try { - $this->dispatch('info', 'Gracefully stopping database, it could take a while depending on the size of the database.'); + $this->authorize('manage', $this->database); + + $this->dispatch('info', 'Gracefully stopping database.'); StopDatabase::dispatch($this->database, false, $this->docker_cleanup); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -73,12 +81,16 @@ public function stop() public function restart() { + $this->authorize('manage', $this->database); + $activity = RestartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } public function start() { + $this->authorize('manage', $this->database); + $activity = StartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index eb80ca6f6..7d6ac3131 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Database; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Livewire\Component; class Import extends Component { + use AuthorizesRequests; + public bool $unsupported = false; public $resource; @@ -128,6 +131,7 @@ public function getContainers() if (is_null($resource)) { abort(404); } + $this->authorize('view', $resource); $this->resource = $resource; $this->server = $this->resource->destination->server; $this->container = $this->resource->uuid; @@ -165,12 +169,15 @@ public function checkFile() public function runImport() { + $this->authorize('update', $this->resource); + if ($this->filename === '') { $this->dispatch('error', 'Please select a file to import.'); return; } try { + $this->importRunning = true; $this->importCommands = []; if (filled($this->customLocation)) { $backupFileName = '/tmp/restore_'.$this->resource->uuid; diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index cfc22aedc..6d21988e7 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -6,59 +6,48 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneKeydb; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Livewire\Attributes\Validate; use Livewire\Component; class General extends Component { - public Server $server; + use AuthorizesRequests; + + public ?Server $server = null; public StandaloneKeydb $database; - #[Validate(['required', 'string'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; - #[Validate(['nullable', 'string'])] public ?string $keydbConf = null; - #[Validate(['required', 'string'])] public string $keydbPassword; - #[Validate(['required', 'string'])] public string $image; - #[Validate(['nullable', 'string'])] public ?string $portsMappings = null; - #[Validate(['nullable', 'boolean'])] public ?bool $isPublic = null; - #[Validate(['nullable', 'integer'])] public ?int $publicPort = null; - #[Validate(['nullable', 'string'])] public ?string $customDockerRunOptions = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrl = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrlPublic = null; - #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; public ?Carbon $certificateValidUntil = null; - #[Validate(['boolean'])] public bool $enable_ssl = false; public function getListeners() @@ -69,15 +58,20 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'refresh' => '$refresh', ]; } public function mount() { try { + $this->authorize('view', $this->database); $this->syncData(); $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); + + return; + } $existingCert = $this->database->sslCertificates()->first(); @@ -89,6 +83,41 @@ public function mount() } } + protected function rules(): array + { + $baseRules = [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'keydbConf' => 'nullable|string', + 'keydbPassword' => 'required|string', + 'image' => 'required|string', + 'portsMappings' => 'nullable|string', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'customDockerRunOptions' => 'nullable|string', + 'dbUrl' => 'nullable|string', + 'dbUrlPublic' => 'nullable|string', + 'isLogDrainEnabled' => 'nullable|boolean', + 'enable_ssl' => 'boolean', + ]; + + return $baseRules; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'keydbPassword.required' => 'The KeyDB Password field is required.', + 'keydbPassword.string' => 'The KeyDB Password must be a string.', + 'image.required' => 'The Docker Image field is required.', + 'image.string' => 'The Docker Image must be a string.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -128,6 +157,8 @@ public function syncData(bool $toModel = false) public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -146,6 +177,8 @@ public function instantSaveAdvanced() public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; @@ -183,6 +216,8 @@ public function databaseProxyStopped() public function submit() { try { + $this->authorize('manageEnvironment', $this->database); + if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -202,6 +237,8 @@ public function submit() public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -212,6 +249,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -220,7 +259,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id) + $caCert = $this->server->sslCertificates() ->where('is_ca_certificate', true) ->first(); diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 174f907c8..429cfc387 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -6,21 +6,50 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneMariadb; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { - protected $listeners = ['refresh']; + use AuthorizesRequests; - public Server $server; + public ?Server $server = null; public StandaloneMariadb $database; + public string $name; + + public ?string $description = null; + + public string $mariadbRootPassword; + + public string $mariadbUser; + + public string $mariadbPassword; + + public string $mariadbDatabase; + + public ?string $mariadbConf = null; + + public string $image; + + public ?string $portsMappings = null; + + public ?bool $isPublic = null; + + public ?int $publicPort = null; + + public bool $isLogDrainEnabled = false; + + public ?string $customDockerRunOptions = null; + + public bool $enableSsl = false; + public ?string $db_url = null; public ?string $db_url_public = null; @@ -33,66 +62,139 @@ public function getListeners() return [ "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'refresh' => '$refresh', ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.mariadb_root_password' => 'required', - 'database.mariadb_user' => 'required', - 'database.mariadb_password' => 'required', - 'database.mariadb_database' => 'required', - 'database.mariadb_conf' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'mariadbRootPassword' => 'required', + 'mariadbUser' => 'required', + 'mariadbPassword' => 'required', + 'mariadbDatabase' => 'required', + 'mariadbConf' => 'nullable', + 'image' => 'required', + 'portsMappings' => 'nullable', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'isLogDrainEnabled' => 'nullable|boolean', + 'customDockerRunOptions' => 'nullable', + 'enableSsl' => 'boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'mariadbRootPassword.required' => 'The Root Password field is required.', + 'mariadbUser.required' => 'The MariaDB User field is required.', + 'mariadbPassword.required' => 'The MariaDB Password field is required.', + 'mariadbDatabase.required' => 'The MariaDB Database field is required.', + 'image.required' => 'The Docker Image field is required.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } protected $validationAttributes = [ - 'database.name' => 'Name', - 'database.description' => 'Description', - 'database.mariadb_root_password' => 'Root Password', - 'database.mariadb_user' => 'User', - 'database.mariadb_password' => 'Password', - 'database.mariadb_database' => 'Database', - 'database.mariadb_conf' => 'MariaDB Configuration', - 'database.image' => 'Image', - 'database.ports_mappings' => 'Port Mapping', - 'database.is_public' => 'Is Public', - 'database.public_port' => 'Public Port', - 'database.custom_docker_run_options' => 'Custom Docker Options', - 'database.enable_ssl' => 'Enable SSL', + 'name' => 'Name', + 'description' => 'Description', + 'mariadbRootPassword' => 'Root Password', + 'mariadbUser' => 'User', + 'mariadbPassword' => 'Password', + 'mariadbDatabase' => 'Database', + 'mariadbConf' => 'MariaDB Configuration', + 'image' => 'Image', + 'portsMappings' => 'Port Mapping', + 'isPublic' => 'Is Public', + 'publicPort' => 'Public Port', + 'customDockerRunOptions' => 'Custom Docker Options', + 'enableSsl' => 'Enable SSL', ]; public function mount() { - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->server = data_get($this->database, 'destination.server'); + try { + $this->authorize('view', $this->database); + $this->syncData(); + $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); - $existingCert = $this->database->sslCertificates()->first(); + return; + } - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->database->name = $this->name; + $this->database->description = $this->description; + $this->database->mariadb_root_password = $this->mariadbRootPassword; + $this->database->mariadb_user = $this->mariadbUser; + $this->database->mariadb_password = $this->mariadbPassword; + $this->database->mariadb_database = $this->mariadbDatabase; + $this->database->mariadb_conf = $this->mariadbConf; + $this->database->image = $this->image; + $this->database->ports_mappings = $this->portsMappings; + $this->database->is_public = $this->isPublic; + $this->database->public_port = $this->publicPort; + $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->custom_docker_run_options = $this->customDockerRunOptions; + $this->database->enable_ssl = $this->enableSsl; + $this->database->save(); + + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + } else { + $this->name = $this->database->name; + $this->description = $this->database->description; + $this->mariadbRootPassword = $this->database->mariadb_root_password; + $this->mariadbUser = $this->database->mariadb_user; + $this->mariadbPassword = $this->database->mariadb_password; + $this->mariadbDatabase = $this->database->mariadb_database; + $this->mariadbConf = $this->database->mariadb_conf; + $this->image = $this->database->image; + $this->portsMappings = $this->database->ports_mappings; + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->enableSsl = $this->database->enable_ssl; + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; } } public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (Exception $e) { @@ -103,11 +205,12 @@ public function instantSaveAdvanced() public function submit() { try { - if (str($this->database->public_port)->isEmpty()) { - $this->database->public_port = null; + $this->authorize('update', $this->database); + + if (str($this->publicPort)->isEmpty()) { + $this->publicPort = null; } - $this->validate(); - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -123,16 +226,18 @@ public function submit() public function instantSave() { try { - if ($this->database->is_public && ! $this->database->public_port) { + $this->authorize('update', $this->database); + + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $this->isPublic = false; return; } - if ($this->database->is_public) { + if ($this->isPublic) { if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->database->is_public = false; + $this->isPublic = false; return; } @@ -142,10 +247,9 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } - $this->db_url_public = $this->database->external_db_url; - $this->database->save(); + $this->syncData(true); } catch (\Throwable $e) { - $this->database->is_public = ! $this->database->is_public; + $this->isPublic = ! $this->isPublic; return handleError($e, $this); } @@ -154,7 +258,9 @@ public function instantSave() public function instantSaveSSL() { try { - $this->database->save(); + $this->authorize('update', $this->database); + + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -164,6 +270,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -172,7 +280,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, @@ -196,6 +304,7 @@ public function regenerateSslCertificate() public function refresh(): void { $this->database->refresh(); + $this->syncData(); } public function render() diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 2ac6e43b7..ae725fa4b 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -6,21 +6,50 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneMongodb; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { - protected $listeners = ['refresh']; + use AuthorizesRequests; - public Server $server; + public ?Server $server = null; public StandaloneMongodb $database; + public string $name; + + public ?string $description = null; + + public ?string $mongoConf = null; + + public string $mongoInitdbRootUsername; + + public string $mongoInitdbRootPassword; + + public string $mongoInitdbDatabase; + + public string $image; + + public ?string $portsMappings = null; + + public ?bool $isPublic = null; + + public ?int $publicPort = null; + + public bool $isLogDrainEnabled = false; + + public ?string $customDockerRunOptions = null; + + public bool $enableSsl = false; + + public ?string $sslMode = null; + public ?string $db_url = null; public ?string $db_url_public = null; @@ -33,66 +62,139 @@ public function getListeners() return [ "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'refresh' => '$refresh', ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.mongo_conf' => 'nullable', - 'database.mongo_initdb_root_username' => 'required', - 'database.mongo_initdb_root_password' => 'required', - 'database.mongo_initdb_database' => 'required', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - 'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'mongoConf' => 'nullable', + 'mongoInitdbRootUsername' => 'required', + 'mongoInitdbRootPassword' => 'required', + 'mongoInitdbDatabase' => 'required', + 'image' => 'required', + 'portsMappings' => 'nullable', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'isLogDrainEnabled' => 'nullable|boolean', + 'customDockerRunOptions' => 'nullable', + 'enableSsl' => 'boolean', + 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', + 'mongoInitdbRootPassword.required' => 'The Root Password field is required.', + 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', + 'image.required' => 'The Docker Image field is required.', + 'publicPort.integer' => 'The Public Port must be an integer.', + 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', + ] + ); + } protected $validationAttributes = [ - 'database.name' => 'Name', - 'database.description' => 'Description', - 'database.mongo_conf' => 'Mongo Configuration', - 'database.mongo_initdb_root_username' => 'Root Username', - 'database.mongo_initdb_root_password' => 'Root Password', - 'database.mongo_initdb_database' => 'Database', - 'database.image' => 'Image', - 'database.ports_mappings' => 'Port Mapping', - 'database.is_public' => 'Is Public', - 'database.public_port' => 'Public Port', - 'database.custom_docker_run_options' => 'Custom Docker Run Options', - 'database.enable_ssl' => 'Enable SSL', - 'database.ssl_mode' => 'SSL Mode', + 'name' => 'Name', + 'description' => 'Description', + 'mongoConf' => 'Mongo Configuration', + 'mongoInitdbRootUsername' => 'Root Username', + 'mongoInitdbRootPassword' => 'Root Password', + 'mongoInitdbDatabase' => 'Database', + 'image' => 'Image', + 'portsMappings' => 'Port Mapping', + 'isPublic' => 'Is Public', + 'publicPort' => 'Public Port', + 'customDockerRunOptions' => 'Custom Docker Run Options', + 'enableSsl' => 'Enable SSL', + 'sslMode' => 'SSL Mode', ]; public function mount() { - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->server = data_get($this->database, 'destination.server'); + try { + $this->authorize('view', $this->database); + $this->syncData(); + $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); - $existingCert = $this->database->sslCertificates()->first(); + return; + } - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->database->name = $this->name; + $this->database->description = $this->description; + $this->database->mongo_conf = $this->mongoConf; + $this->database->mongo_initdb_root_username = $this->mongoInitdbRootUsername; + $this->database->mongo_initdb_root_password = $this->mongoInitdbRootPassword; + $this->database->mongo_initdb_database = $this->mongoInitdbDatabase; + $this->database->image = $this->image; + $this->database->ports_mappings = $this->portsMappings; + $this->database->is_public = $this->isPublic; + $this->database->public_port = $this->publicPort; + $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->custom_docker_run_options = $this->customDockerRunOptions; + $this->database->enable_ssl = $this->enableSsl; + $this->database->ssl_mode = $this->sslMode; + $this->database->save(); + + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + } else { + $this->name = $this->database->name; + $this->description = $this->database->description; + $this->mongoConf = $this->database->mongo_conf; + $this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username; + $this->mongoInitdbRootPassword = $this->database->mongo_initdb_root_password; + $this->mongoInitdbDatabase = $this->database->mongo_initdb_database; + $this->image = $this->database->image; + $this->portsMappings = $this->database->ports_mappings; + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->enableSsl = $this->database->enable_ssl; + $this->sslMode = $this->database->ssl_mode; + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; } } public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (Exception $e) { @@ -103,14 +205,15 @@ public function instantSaveAdvanced() public function submit() { try { - if (str($this->database->public_port)->isEmpty()) { - $this->database->public_port = null; + $this->authorize('update', $this->database); + + if (str($this->publicPort)->isEmpty()) { + $this->publicPort = null; } - if (str($this->database->mongo_conf)->isEmpty()) { - $this->database->mongo_conf = null; + if (str($this->mongoConf)->isEmpty()) { + $this->mongoConf = null; } - $this->validate(); - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -126,16 +229,18 @@ public function submit() public function instantSave() { try { - if ($this->database->is_public && ! $this->database->public_port) { + $this->authorize('update', $this->database); + + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $this->isPublic = false; return; } - if ($this->database->is_public) { + if ($this->isPublic) { if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->database->is_public = false; + $this->isPublic = false; return; } @@ -145,16 +250,15 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } - $this->db_url_public = $this->database->external_db_url; - $this->database->save(); + $this->syncData(true); } catch (\Throwable $e) { - $this->database->is_public = ! $this->database->is_public; + $this->isPublic = ! $this->isPublic; return handleError($e, $this); } } - public function updatedDatabaseSslMode() + public function updatedSslMode() { $this->instantSaveSSL(); } @@ -162,7 +266,9 @@ public function updatedDatabaseSslMode() public function instantSaveSSL() { try { - $this->database->save(); + $this->authorize('update', $this->database); + + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -172,6 +278,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -180,7 +288,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, @@ -204,6 +312,7 @@ public function regenerateSslCertificate() public function refresh(): void { $this->database->refresh(); + $this->syncData(); } public function render() diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index ea0ea4691..cffedcd23 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -6,20 +6,51 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneMysql; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { - protected $listeners = ['refresh']; + use AuthorizesRequests; public StandaloneMysql $database; - public Server $server; + public ?Server $server = null; + + public string $name; + + public ?string $description = null; + + public string $mysqlRootPassword; + + public string $mysqlUser; + + public string $mysqlPassword; + + public string $mysqlDatabase; + + public ?string $mysqlConf = null; + + public string $image; + + public ?string $portsMappings = null; + + public ?bool $isPublic = null; + + public ?int $publicPort = null; + + public bool $isLogDrainEnabled = false; + + public ?string $customDockerRunOptions = null; + + public bool $enableSsl = false; + + public ?string $sslMode = null; public ?string $db_url = null; @@ -33,68 +64,144 @@ public function getListeners() return [ "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'refresh' => '$refresh', ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.mysql_root_password' => 'required', - 'database.mysql_user' => 'required', - 'database.mysql_password' => 'required', - 'database.mysql_database' => 'required', - 'database.mysql_conf' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - 'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'mysqlRootPassword' => 'required', + 'mysqlUser' => 'required', + 'mysqlPassword' => 'required', + 'mysqlDatabase' => 'required', + 'mysqlConf' => 'nullable', + 'image' => 'required', + 'portsMappings' => 'nullable', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'isLogDrainEnabled' => 'nullable|boolean', + 'customDockerRunOptions' => 'nullable', + 'enableSsl' => 'boolean', + 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'mysqlRootPassword.required' => 'The Root Password field is required.', + 'mysqlUser.required' => 'The MySQL User field is required.', + 'mysqlPassword.required' => 'The MySQL Password field is required.', + 'mysqlDatabase.required' => 'The MySQL Database field is required.', + 'image.required' => 'The Docker Image field is required.', + 'publicPort.integer' => 'The Public Port must be an integer.', + 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', + ] + ); + } protected $validationAttributes = [ - 'database.name' => 'Name', - 'database.description' => 'Description', - 'database.mysql_root_password' => 'Root Password', - 'database.mysql_user' => 'User', - 'database.mysql_password' => 'Password', - 'database.mysql_database' => 'Database', - 'database.mysql_conf' => 'MySQL Configuration', - 'database.image' => 'Image', - 'database.ports_mappings' => 'Port Mapping', - 'database.is_public' => 'Is Public', - 'database.public_port' => 'Public Port', - 'database.custom_docker_run_options' => 'Custom Docker Run Options', - 'database.enable_ssl' => 'Enable SSL', - 'database.ssl_mode' => 'SSL Mode', + 'name' => 'Name', + 'description' => 'Description', + 'mysqlRootPassword' => 'Root Password', + 'mysqlUser' => 'User', + 'mysqlPassword' => 'Password', + 'mysqlDatabase' => 'Database', + 'mysqlConf' => 'MySQL Configuration', + 'image' => 'Image', + 'portsMappings' => 'Port Mapping', + 'isPublic' => 'Is Public', + 'publicPort' => 'Public Port', + 'customDockerRunOptions' => 'Custom Docker Run Options', + 'enableSsl' => 'Enable SSL', + 'sslMode' => 'SSL Mode', ]; public function mount() { - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->server = data_get($this->database, 'destination.server'); + try { + $this->authorize('view', $this->database); + $this->syncData(); + $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); - $existingCert = $this->database->sslCertificates()->first(); + return; + } - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->database->name = $this->name; + $this->database->description = $this->description; + $this->database->mysql_root_password = $this->mysqlRootPassword; + $this->database->mysql_user = $this->mysqlUser; + $this->database->mysql_password = $this->mysqlPassword; + $this->database->mysql_database = $this->mysqlDatabase; + $this->database->mysql_conf = $this->mysqlConf; + $this->database->image = $this->image; + $this->database->ports_mappings = $this->portsMappings; + $this->database->is_public = $this->isPublic; + $this->database->public_port = $this->publicPort; + $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->custom_docker_run_options = $this->customDockerRunOptions; + $this->database->enable_ssl = $this->enableSsl; + $this->database->ssl_mode = $this->sslMode; + $this->database->save(); + + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + } else { + $this->name = $this->database->name; + $this->description = $this->database->description; + $this->mysqlRootPassword = $this->database->mysql_root_password; + $this->mysqlUser = $this->database->mysql_user; + $this->mysqlPassword = $this->database->mysql_password; + $this->mysqlDatabase = $this->database->mysql_database; + $this->mysqlConf = $this->database->mysql_conf; + $this->image = $this->database->image; + $this->portsMappings = $this->database->ports_mappings; + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->enableSsl = $this->database->enable_ssl; + $this->sslMode = $this->database->ssl_mode; + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; } } public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (Exception $e) { @@ -105,11 +212,12 @@ public function instantSaveAdvanced() public function submit() { try { - if (str($this->database->public_port)->isEmpty()) { - $this->database->public_port = null; + $this->authorize('update', $this->database); + + if (str($this->publicPort)->isEmpty()) { + $this->publicPort = null; } - $this->validate(); - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -125,16 +233,18 @@ public function submit() public function instantSave() { try { - if ($this->database->is_public && ! $this->database->public_port) { + $this->authorize('update', $this->database); + + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $this->isPublic = false; return; } - if ($this->database->is_public) { + if ($this->isPublic) { if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->database->is_public = false; + $this->isPublic = false; return; } @@ -144,16 +254,15 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } - $this->db_url_public = $this->database->external_db_url; - $this->database->save(); + $this->syncData(true); } catch (\Throwable $e) { - $this->database->is_public = ! $this->database->is_public; + $this->isPublic = ! $this->isPublic; return handleError($e, $this); } } - public function updatedDatabaseSslMode() + public function updatedSslMode() { $this->instantSaveSSL(); } @@ -161,7 +270,9 @@ public function updatedDatabaseSslMode() public function instantSaveSSL() { try { - $this->database->save(); + $this->authorize('update', $this->database); + + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -171,6 +282,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -179,7 +292,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, @@ -203,6 +316,7 @@ public function regenerateSslCertificate() public function refresh(): void { $this->database->refresh(); + $this->syncData(); } public function render() diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index d512445b7..3240aadd2 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -6,18 +6,55 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandalonePostgresql; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + public StandalonePostgresql $database; - public Server $server; + public ?Server $server = null; + + public string $name; + + public ?string $description = null; + + public string $postgresUser; + + public string $postgresPassword; + + public string $postgresDb; + + public ?string $postgresInitdbArgs = null; + + public ?string $postgresHostAuthMethod = null; + + public ?string $postgresConf = null; + + public ?array $initScripts = null; + + public string $image; + + public ?string $portsMappings = null; + + public ?bool $isPublic = null; + + public ?int $publicPort = null; + + public bool $isLogDrainEnabled = false; + + public ?string $customDockerRunOptions = null; + + public bool $enableSsl = false; + + public ?string $sslMode = null; public string $new_filename; @@ -35,74 +72,153 @@ public function getListeners() return [ "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'refresh' => '$refresh', 'save_init_script', 'delete_init_script', ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.postgres_user' => 'required', - 'database.postgres_password' => 'required', - 'database.postgres_db' => 'required', - 'database.postgres_initdb_args' => 'nullable', - 'database.postgres_host_auth_method' => 'nullable', - 'database.postgres_conf' => 'nullable', - 'database.init_scripts' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - 'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'postgresUser' => 'required', + 'postgresPassword' => 'required', + 'postgresDb' => 'required', + 'postgresInitdbArgs' => 'nullable', + 'postgresHostAuthMethod' => 'nullable', + 'postgresConf' => 'nullable', + 'initScripts' => 'nullable', + 'image' => 'required', + 'portsMappings' => 'nullable', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'isLogDrainEnabled' => 'nullable|boolean', + 'customDockerRunOptions' => 'nullable', + 'enableSsl' => 'boolean', + 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'postgresUser.required' => 'The Postgres User field is required.', + 'postgresPassword.required' => 'The Postgres Password field is required.', + 'postgresDb.required' => 'The Postgres Database field is required.', + 'image.required' => 'The Docker Image field is required.', + 'publicPort.integer' => 'The Public Port must be an integer.', + 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', + ] + ); + } protected $validationAttributes = [ - 'database.name' => 'Name', - 'database.description' => 'Description', - 'database.postgres_user' => 'Postgres User', - 'database.postgres_password' => 'Postgres Password', - 'database.postgres_db' => 'Postgres DB', - 'database.postgres_initdb_args' => 'Postgres Initdb Args', - 'database.postgres_host_auth_method' => 'Postgres Host Auth Method', - 'database.postgres_conf' => 'Postgres Configuration', - 'database.init_scripts' => 'Init Scripts', - 'database.image' => 'Image', - 'database.ports_mappings' => 'Port Mapping', - 'database.is_public' => 'Is Public', - 'database.public_port' => 'Public Port', - 'database.custom_docker_run_options' => 'Custom Docker Run Options', - 'database.enable_ssl' => 'Enable SSL', - 'database.ssl_mode' => 'SSL Mode', + 'name' => 'Name', + 'description' => 'Description', + 'postgresUser' => 'Postgres User', + 'postgresPassword' => 'Postgres Password', + 'postgresDb' => 'Postgres DB', + 'postgresInitdbArgs' => 'Postgres Initdb Args', + 'postgresHostAuthMethod' => 'Postgres Host Auth Method', + 'postgresConf' => 'Postgres Configuration', + 'initScripts' => 'Init Scripts', + 'image' => 'Image', + 'portsMappings' => 'Port Mapping', + 'isPublic' => 'Is Public', + 'publicPort' => 'Public Port', + 'customDockerRunOptions' => 'Custom Docker Run Options', + 'enableSsl' => 'Enable SSL', + 'sslMode' => 'SSL Mode', ]; public function mount() { - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->server = data_get($this->database, 'destination.server'); + try { + $this->authorize('view', $this->database); + $this->syncData(); + $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); - $existingCert = $this->database->sslCertificates()->first(); + return; + } - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } + } catch (Exception $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->database->name = $this->name; + $this->database->description = $this->description; + $this->database->postgres_user = $this->postgresUser; + $this->database->postgres_password = $this->postgresPassword; + $this->database->postgres_db = $this->postgresDb; + $this->database->postgres_initdb_args = $this->postgresInitdbArgs; + $this->database->postgres_host_auth_method = $this->postgresHostAuthMethod; + $this->database->postgres_conf = $this->postgresConf; + $this->database->init_scripts = $this->initScripts; + $this->database->image = $this->image; + $this->database->ports_mappings = $this->portsMappings; + $this->database->is_public = $this->isPublic; + $this->database->public_port = $this->publicPort; + $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->custom_docker_run_options = $this->customDockerRunOptions; + $this->database->enable_ssl = $this->enableSsl; + $this->database->ssl_mode = $this->sslMode; + $this->database->save(); + + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + } else { + $this->name = $this->database->name; + $this->description = $this->database->description; + $this->postgresUser = $this->database->postgres_user; + $this->postgresPassword = $this->database->postgres_password; + $this->postgresDb = $this->database->postgres_db; + $this->postgresInitdbArgs = $this->database->postgres_initdb_args; + $this->postgresHostAuthMethod = $this->database->postgres_host_auth_method; + $this->postgresConf = $this->database->postgres_conf; + $this->initScripts = $this->database->init_scripts; + $this->image = $this->database->image; + $this->portsMappings = $this->database->ports_mappings; + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->enableSsl = $this->database->enable_ssl; + $this->sslMode = $this->database->ssl_mode; + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; } } public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (Exception $e) { @@ -110,7 +226,7 @@ public function instantSaveAdvanced() } } - public function updatedDatabaseSslMode() + public function updatedSslMode() { $this->instantSaveSSL(); } @@ -118,10 +234,10 @@ public function updatedDatabaseSslMode() public function instantSaveSSL() { try { - $this->database->save(); + $this->authorize('update', $this->database); + + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } catch (Exception $e) { return handleError($e, $this); } @@ -130,6 +246,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -138,7 +256,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->common_name, @@ -162,16 +280,18 @@ public function regenerateSslCertificate() public function instantSave() { try { - if ($this->database->is_public && ! $this->database->public_port) { + $this->authorize('update', $this->database); + + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $this->isPublic = false; return; } - if ($this->database->is_public) { + if ($this->isPublic) { if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->database->is_public = false; + $this->isPublic = false; return; } @@ -181,10 +301,9 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } - $this->db_url_public = $this->database->external_db_url; - $this->database->save(); + $this->syncData(true); } catch (\Throwable $e) { - $this->database->is_public = ! $this->database->is_public; + $this->isPublic = ! $this->isPublic; return handleError($e, $this); } @@ -192,7 +311,9 @@ public function instantSave() public function save_init_script($script) { - $initScripts = collect($this->database->init_scripts ?? []); + $this->authorize('update', $this->database); + + $initScripts = collect($this->initScripts ?? []); $existingScript = $initScripts->firstWhere('filename', $script['filename']); $oldScript = $initScripts->firstWhere('index', $script['index']); @@ -228,7 +349,7 @@ public function save_init_script($script) $initScripts->push($script); } - $this->database->init_scripts = $initScripts->values() + $this->initScripts = $initScripts->values() ->map(function ($item, $index) { $item['index'] = $index; @@ -236,13 +357,15 @@ public function save_init_script($script) }) ->all(); - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Init script saved and updated.'); } public function delete_init_script($script) { - $collection = collect($this->database->init_scripts); + $this->authorize('update', $this->database); + + $collection = collect($this->initScripts); $found = $collection->firstWhere('filename', $script['filename']); if ($found) { $container_name = $this->database->uuid; @@ -267,8 +390,8 @@ public function delete_init_script($script) }) ->all(); - $this->database->init_scripts = $updatedScripts; - $this->database->save(); + $this->initScripts = $updatedScripts; + $this->syncData(true); $this->dispatch('refresh')->self(); $this->dispatch('success', 'Init script deleted from the database and the server.'); } @@ -276,27 +399,29 @@ public function delete_init_script($script) public function save_new_init_script() { + $this->authorize('update', $this->database); + $this->validate([ 'new_filename' => 'required|string', 'new_content' => 'required|string', ]); - $found = collect($this->database->init_scripts)->firstWhere('filename', $this->new_filename); + $found = collect($this->initScripts)->firstWhere('filename', $this->new_filename); if ($found) { $this->dispatch('error', 'Filename already exists.'); return; } - if (! isset($this->database->init_scripts)) { - $this->database->init_scripts = []; + if (! isset($this->initScripts)) { + $this->initScripts = []; } - $this->database->init_scripts = array_merge($this->database->init_scripts, [ + $this->initScripts = array_merge($this->initScripts, [ [ - 'index' => count($this->database->init_scripts), + 'index' => count($this->initScripts), 'filename' => $this->new_filename, 'content' => $this->new_content, ], ]); - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Init script added.'); $this->new_content = ''; $this->new_filename = ''; @@ -305,11 +430,12 @@ public function save_new_init_script() public function submit() { try { - if (str($this->database->public_port)->isEmpty()) { - $this->database->public_port = null; + $this->authorize('update', $this->database); + + if (str($this->publicPort)->isEmpty()) { + $this->publicPort = null; } - $this->validate(); - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); } catch (Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index f03f1256d..846614d21 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -6,28 +6,51 @@ use App\Actions\Database\StopDatabaseProxy; use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use App\Models\StandaloneRedis; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { - public Server $server; + use AuthorizesRequests; + + public ?Server $server = null; public StandaloneRedis $database; - public string $redis_username; + public string $name; - public ?string $redis_password; + public ?string $description = null; - public string $redis_version; + public ?string $redisConf = null; - public ?string $db_url = null; + public string $image; - public ?string $db_url_public = null; + public ?string $portsMappings = null; + + public ?bool $isPublic = null; + + public ?int $publicPort = null; + + public bool $isLogDrainEnabled = false; + + public ?string $customDockerRunOptions = null; + + public string $redisUsername; + + public string $redisPassword; + + public string $redisVersion; + + public ?string $dbUrl = null; + + public ?string $dbUrlPublic = null; + + public bool $enableSsl = false; public ?Carbon $certificateValidUntil = null; @@ -38,60 +61,128 @@ public function getListeners() return [ "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', 'envsUpdated' => 'refresh', - 'refresh', ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.redis_conf' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'redis_username' => 'required', - 'redis_password' => 'required', - 'database.enable_ssl' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'redisConf' => 'nullable', + 'image' => 'required', + 'portsMappings' => 'nullable', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'isLogDrainEnabled' => 'nullable|boolean', + 'customDockerRunOptions' => 'nullable', + 'redisUsername' => 'required', + 'redisPassword' => 'required', + 'enableSsl' => 'boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'image.required' => 'The Docker Image field is required.', + 'publicPort.integer' => 'The Public Port must be an integer.', + 'redisUsername.required' => 'The Redis Username field is required.', + 'redisPassword.required' => 'The Redis Password field is required.', + ] + ); + } protected $validationAttributes = [ - 'database.name' => 'Name', - 'database.description' => 'Description', - 'database.redis_conf' => 'Redis Configuration', - 'database.image' => 'Image', - 'database.ports_mappings' => 'Port Mapping', - 'database.is_public' => 'Is Public', - 'database.public_port' => 'Public Port', - 'database.custom_docker_run_options' => 'Custom Docker Options', - 'redis_username' => 'Redis Username', - 'redis_password' => 'Redis Password', - 'database.enable_ssl' => 'Enable SSL', + 'name' => 'Name', + 'description' => 'Description', + 'redisConf' => 'Redis Configuration', + 'image' => 'Image', + 'portsMappings' => 'Port Mapping', + 'isPublic' => 'Is Public', + 'publicPort' => 'Public Port', + 'customDockerRunOptions' => 'Custom Docker Options', + 'redisUsername' => 'Redis Username', + 'redisPassword' => 'Redis Password', + 'enableSsl' => 'Enable SSL', ]; public function mount() { - $this->server = data_get($this->database, 'destination.server'); - $this->refreshView(); - $existingCert = $this->database->sslCertificates()->first(); + try { + $this->authorize('view', $this->database); + $this->syncData(); + $this->server = data_get($this->database, 'destination.server'); + if (! $this->server) { + $this->dispatch('error', 'Database destination server is not configured.'); - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; + return; + } + + $existingCert = $this->database->sslCertificates()->first(); + + if ($existingCert) { + $this->certificateValidUntil = $existingCert->valid_until; + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->validate(); + $this->database->name = $this->name; + $this->database->description = $this->description; + $this->database->redis_conf = $this->redisConf; + $this->database->image = $this->image; + $this->database->ports_mappings = $this->portsMappings; + $this->database->is_public = $this->isPublic; + $this->database->public_port = $this->publicPort; + $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->database->custom_docker_run_options = $this->customDockerRunOptions; + $this->database->enable_ssl = $this->enableSsl; + $this->database->save(); + + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + } else { + $this->name = $this->database->name; + $this->description = $this->database->description; + $this->redisConf = $this->database->redis_conf; + $this->image = $this->database->image; + $this->portsMappings = $this->database->ports_mappings; + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; + $this->customDockerRunOptions = $this->database->custom_docker_run_options; + $this->enableSsl = $this->database->enable_ssl; + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->redisVersion = $this->database->getRedisVersion(); + $this->redisUsername = $this->database->redis_username; + $this->redisPassword = $this->database->redis_password; } } public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; + $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } - $this->database->save(); + $this->syncData(true); $this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (Exception $e) { @@ -102,20 +193,21 @@ public function instantSaveAdvanced() public function submit() { try { - $this->validate(); + $this->authorize('manageEnvironment', $this->database); - if (version_compare($this->redis_version, '6.0', '>=')) { + $this->syncData(true); + + if (version_compare($this->redisVersion, '6.0', '>=')) { $this->database->runtime_environment_variables()->updateOrCreate( ['key' => 'REDIS_USERNAME'], - ['value' => $this->redis_username, 'resourceable_id' => $this->database->id] + ['value' => $this->redisUsername, 'resourceable_id' => $this->database->id] ); } $this->database->runtime_environment_variables()->updateOrCreate( ['key' => 'REDIS_PASSWORD'], - ['value' => $this->redis_password, 'resourceable_id' => $this->database->id] + ['value' => $this->redisPassword, 'resourceable_id' => $this->database->id] ); - $this->database->save(); $this->dispatch('success', 'Database updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -127,16 +219,18 @@ public function submit() public function instantSave() { try { - if ($this->database->is_public && ! $this->database->public_port) { + $this->authorize('update', $this->database); + + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $this->isPublic = false; return; } - if ($this->database->is_public) { + if ($this->isPublic) { if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->database->is_public = false; + $this->isPublic = false; return; } @@ -146,10 +240,11 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } - $this->db_url_public = $this->database->external_db_url; - $this->database->save(); + $this->dbUrlPublic = $this->database->external_db_url; + $this->syncData(true); } catch (\Throwable $e) { - $this->database->is_public = ! $this->database->is_public; + $this->isPublic = ! $this->isPublic; + $this->syncData(true); return handleError($e, $this); } @@ -158,7 +253,9 @@ public function instantSave() public function instantSaveSSL() { try { - $this->database->save(); + $this->authorize('update', $this->database); + + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { return handleError($e, $this); @@ -168,6 +265,8 @@ public function instantSaveSSL() public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -176,7 +275,7 @@ public function regenerateSslCertificate() return; } - $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first(); + $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); SslHelper::generateSslCertificate( commonName: $existingCert->commonName, @@ -200,16 +299,7 @@ public function regenerateSslCertificate() public function refresh(): void { $this->database->refresh(); - $this->refreshView(); - } - - private function refreshView() - { - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->redis_version = $this->database->getRedisVersion(); - $this->redis_username = $this->database->redis_username; - $this->redis_password = $this->database->redis_password; + $this->syncData(); } public function render() diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index 51d8cb33e..1cf5e53f6 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class ScheduledBackups extends Component { + use AuthorizesRequests; + public $database; public $parameters; @@ -53,6 +56,8 @@ public function setSelectedBackup($backupId, $force = false) public function setCustomType() { + $this->authorize('update', $this->database); + $this->database->custom_type = $this->custom_type; $this->database->save(); $this->dispatch('success', 'Database type set.'); @@ -61,7 +66,10 @@ public function setCustomType() public function delete($scheduled_backup_id): void { - $this->database->scheduledBackups->find($scheduled_backup_id)->delete(); + $backup = $this->database->scheduledBackups->find($scheduled_backup_id); + $this->authorize('manageBackups', $this->database); + + $backup->delete(); $this->dispatch('success', 'Scheduled backup deleted.'); $this->refreshScheduledBackups(); } diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 1ee5de269..e97206081 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project; use App\Models\Environment; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class DeleteEnvironment extends Component { + use AuthorizesRequests; + public int $environment_id; public bool $disabled = false; @@ -31,6 +34,8 @@ public function delete() 'environment_id' => 'required|int', ]); $environment = Environment::findOrFail($this->environment_id); + $this->authorize('delete', $environment); + if ($environment->isEmpty()) { $environment->delete(); diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index f320a19b0..26b35b2e7 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project; use App\Models\Project; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class DeleteProject extends Component { + use AuthorizesRequests; + public array $parameters; public int $project_id; @@ -27,6 +30,8 @@ public function delete() 'project_id' => 'required|int', ]); $project = Project::findOrFail($this->project_id); + $this->authorize('delete', $project); + if ($project->isEmpty()) { $project->delete(); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 463febb10..a2d73eb5f 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -3,19 +3,30 @@ namespace App\Livewire\Project; use App\Models\Project; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; class Edit extends Component { public Project $project; - #[Validate(['required', 'string', 'min:3', 'max:255'])] public string $name; - #[Validate(['nullable', 'string', 'max:255'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function mount(string $project_uuid) { try { diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index e98b088ec..d57be2cc8 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -4,8 +4,8 @@ use App\Models\Application; use App\Models\Project; +use App\Support\ValidationPatterns; use Livewire\Attributes\Locked; -use Livewire\Attributes\Validate; use Livewire\Component; class EnvironmentEdit extends Component @@ -17,12 +17,23 @@ class EnvironmentEdit extends Component #[Locked] public $environment; - #[Validate(['required', 'string', 'min:3', 'max:255'])] public string $name; - #[Validate(['nullable', 'string', 'max:255'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function mount(string $project_uuid, string $environment_uuid) { try { diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 5347d74f0..a27a3652f 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -20,6 +20,15 @@ public function mount() $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); $this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) { $project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]); + $project->canUpdate = auth()->user()->can('update', $project); + $project->canCreateResource = auth()->user()->can('createAnyResource'); + $firstEnvironment = $project->environments->first(); + $project->addResourceRoute = $firstEnvironment + ? route('project.resource.create', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $firstEnvironment->uuid, + ]) + : null; return $project; }); diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 3d47ffae5..a88a62d88 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -25,21 +25,7 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); if (isDev()) { - $this->dockerComposeRaw = 'services: - appsmith: - build: - context: . - dockerfile_inline: | - FROM nginx - ARG GIT_COMMIT - ARG GIT_BRANCH - RUN echo "Hello World ${GIT_COMMIT} ${GIT_BRANCH}" - args: - - GIT_COMMIT=cdc3b19 - - GIT_BRANCH=${GIT_BRANCH} - environment: - - APPSMITH_MAIL_ENABLED=${APPSMITH_MAIL_ENABLED} - '; + $this->dockerComposeRaw = file_get_contents(base_path('templates/test-database-detection.yaml')); } } @@ -51,6 +37,10 @@ public function submit() 'dockerComposeRaw' => 'required', ]); $this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + + // Validate for command injection BEFORE saving to database + validateDockerComposeForInjection($this->dockerComposeRaw); + $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); @@ -77,7 +67,6 @@ public function submit() EnvironmentVariable::create([ 'key' => $key, 'value' => $variable, - 'is_build_time' => false, 'is_preview' => false, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 7d68ce068..9d04ca9a5 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -12,7 +12,11 @@ class DockerImage extends Component { - public string $dockerImage = ''; + public string $imageName = ''; + + public string $imageTag = ''; + + public string $imageSha256 = ''; public array $parameters; @@ -24,14 +28,88 @@ public function mount() $this->query = request()->query(); } + /** + * Auto-parse image name when user pastes a complete Docker image reference + * Examples: + * - nginx:stable-alpine3.21-perl@sha256:4e272eef... + * - ghcr.io/user/app:v1.2.3 + * - nginx@sha256:abc123... + */ + public function updatedImageName(): void + { + if (empty($this->imageName)) { + return; + } + + // Don't auto-parse if user has already manually filled tag or sha256 fields + if (! empty($this->imageTag) || ! empty($this->imageSha256)) { + return; + } + + // Only auto-parse if the image name contains a tag (:) or digest (@) + if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) { + return; + } + + try { + $parser = new DockerImageParser; + $parser->parse($this->imageName); + + // Extract the base image name (without tag/digest) + $baseImageName = $parser->getFullImageNameWithoutTag(); + + // Only update if parsing resulted in different base name + // This prevents unnecessary updates when user types just the name + if ($baseImageName !== $this->imageName) { + if ($parser->isImageHash()) { + // It's a SHA256 digest (takes priority over tag) + $this->imageSha256 = $parser->getTag(); + $this->imageTag = ''; + } elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) { + // It's a regular tag (only set if not default 'latest' or explicitly specified) + $this->imageTag = $parser->getTag(); + $this->imageSha256 = ''; + } + + // Update imageName to just the base name + $this->imageName = $baseImageName; + } + } catch (\Exception $e) { + // If parsing fails, leave the image name as-is + // User will see validation error on submit + } + } + public function submit() { $this->validate([ - 'dockerImage' => 'required', + 'imageName' => ['required', 'string'], + 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); + // Validate that either tag or sha256 is provided, but not both + if ($this->imageTag && $this->imageSha256) { + $this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.'); + $this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.'); + + return; + } + + // Build the full Docker image string + if ($this->imageSha256) { + // Strip 'sha256:' prefix if user pasted it + $sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256)); + $dockerImage = $this->imageName.'@sha256:'.$sha256Hash; + } elseif ($this->imageTag) { + $dockerImage = $this->imageName.':'.$this->imageTag; + } else { + $dockerImage = $this->imageName.':latest'; + } + + // Parse using DockerImageParser to normalize the image reference $parser = new DockerImageParser; - $parser->parse($this->dockerImage); + $parser->parse($dockerImage); $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); @@ -45,6 +123,16 @@ public function submit() $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + + // Append @sha256 to image name if using digest and not already present + $imageName = $parser->getFullImageNameWithoutTag(); + if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) { + $imageName .= '@sha256'; + } + + // Determine the image tag based on whether it's a hash or regular tag + $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); + $application = Application::create([ 'name' => 'docker-image-'.new Cuid2, 'repository_project_id' => 0, @@ -52,15 +140,15 @@ public function submit() 'git_branch' => 'main', 'build_pack' => 'dockerimage', 'ports_exposes' => 80, - 'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(), - 'docker_registry_image_tag' => $parser->getTag(), + 'docker_registry_image_name' => $imageName, + 'docker_registry_image_tag' => $imageTag, 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, 'health_check_enabled' => false, ]); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ 'name' => 'docker-image-'.$application->uuid, 'fqdn' => $fqdn, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index b1b0aef15..27ecacb99 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -7,6 +7,7 @@ use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Rules\ValidGitBranch; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; use Livewire\Component; @@ -54,7 +55,7 @@ class GithubPrivateRepository extends Component public ?string $publish_directory = null; // In case of docker compose - public ?string $base_directory = null; + public ?string $base_directory = '/'; public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -142,7 +143,13 @@ public function loadBranches() protected function loadBranchByPage() { - $response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}"); + $response = Http::GitHub($this->github_app->api_url, $this->token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [ + 'per_page' => 100, + 'page' => $this->page, + ]); $json = $response->json(); if ($response->status() !== 200) { return $this->dispatch('error', $json['message']); @@ -155,6 +162,21 @@ protected function loadBranchByPage() public function submit() { try { + // Validate git repository parts and branch + $validator = validator([ + 'selected_repository_owner' => $this->selected_repository_owner, + 'selected_repository_repo' => $this->selected_repository_repo, + 'selected_branch_name' => $this->selected_branch_name, + ], [ + 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', + 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', + 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); + } + $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); if (! $destination) { @@ -171,11 +193,12 @@ public function submit() $application = Application::create([ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, - 'git_repository' => "{$this->selected_repository_owner}/{$this->selected_repository_repo}", - 'git_branch' => $this->selected_branch_name, + 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), + 'git_branch' => str($this->selected_branch_name)->trim()->toString(), 'build_pack' => $this->build_pack, 'ports_exposes' => $this->port, 'publish_directory' => $this->publish_directory, + 'base_directory' => $this->base_directory, 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, @@ -190,9 +213,8 @@ public function submit() } if ($this->build_pack === 'dockercompose') { $application['docker_compose_location'] = $this->docker_compose_location; - $application['base_directory'] = $this->base_directory; } - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 01b0c9ae8..77b106200 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -9,6 +9,8 @@ use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Illuminate\Support\Str; use Livewire\Component; use Spatie\Url\Url; @@ -53,17 +55,29 @@ class GithubPrivateRepositoryDeployKey extends Component private ?string $git_host = null; - private string $git_repository; + private ?string $git_repository = null; protected $rules = [ - 'repository_url' => 'required', - 'branch' => 'required|string', + 'repository_url' => ['required', 'string'], + 'branch' => ['required', 'string'], 'port' => 'required|numeric', 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', ]; + protected function rules() + { + return [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + 'branch' => ['required', 'string', new ValidGitBranch], + 'port' => 'required|numeric', + 'is_static' => 'required|boolean', + 'publish_directory' => 'nullable|string', + 'build_pack' => 'required|string', + ]; + } + protected $validationAttributes = [ 'repository_url' => 'Repository', 'branch' => 'Branch', @@ -76,7 +90,7 @@ class GithubPrivateRepositoryDeployKey extends Component public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; } $this->parameters = get_route_parameters(); $this->query = request()->query(); @@ -135,6 +149,9 @@ public function submit() $this->get_git_source(); + // Note: git_repository has already been validated and transformed in get_git_source() + // It may now be in SSH format (git@host:repo.git) which is valid for deploy keys + $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); if ($this->git_source === 'other') { @@ -177,7 +194,7 @@ public function submit() $application->settings->is_static = $this->is_static; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_random_name($application->uuid); $application->save(); @@ -194,6 +211,15 @@ public function submit() private function get_git_source() { + // Validate repository URL before parsing + $validator = validator(['repository_url' => $this->repository_url], [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); @@ -206,8 +232,10 @@ private function get_git_source() if (str($this->repository_url)->startsWith('http')) { $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); + // Convert to SSH format for deploy key usage $this->git_repository = Str::finish("git@$this->git_host:$this->git_repository", '.git'); } else { + // If it's already in SSH format, just use it as-is $this->git_repository = $this->repository_url; } $this->git_source = 'other'; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 45b3b5726..89814ee7f 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -9,6 +9,8 @@ use App\Models\Service; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Carbon\Carbon; use Livewire\Component; use Spatie\Url\Url; @@ -62,7 +64,7 @@ class PublicGitRepository extends Component public bool $new_compose_services = false; protected $rules = [ - 'repository_url' => 'required|url', + 'repository_url' => ['required', 'string'], 'port' => 'required|numeric', 'isStatic' => 'required|boolean', 'publish_directory' => 'nullable|string', @@ -71,6 +73,20 @@ class PublicGitRepository extends Component 'docker_compose_location' => 'nullable|string', ]; + protected function rules() + { + return [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + 'port' => 'required|numeric', + 'isStatic' => 'required|boolean', + 'publish_directory' => 'nullable|string', + 'build_pack' => 'required|string', + 'base_directory' => 'nullable|string', + 'docker_compose_location' => 'nullable|string', + 'git_branch' => ['required', 'string', new ValidGitBranch], + ]; + } + protected $validationAttributes = [ 'repository_url' => 'repository', 'port' => 'port', @@ -84,7 +100,7 @@ class PublicGitRepository extends Component public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; $this->port = 3000; } $this->parameters = get_route_parameters(); @@ -141,6 +157,15 @@ public function instantSave() public function loadBranch() { try { + // Validate repository URL + $validator = validator(['repository_url' => $this->repository_url], [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); + } + if (str($this->repository_url)->startsWith('git@')) { $github_instance = str($this->repository_url)->after('git@')->before(':'); $repository = str($this->repository_url)->after(':')->before('.git'); @@ -151,13 +176,16 @@ public function loadBranch() str($this->repository_url)->startsWith('http://')) && ! str($this->repository_url)->endsWith('.git') && (! str($this->repository_url)->contains('github.com') || - ! str($this->repository_url)->contains('git.sr.ht')) + ! str($this->repository_url)->contains('git.sr.ht')) && + ! str($this->repository_url)->contains('tangled') ) { + $this->repository_url = $this->repository_url.'.git'; } if (str($this->repository_url)->contains('github.com') && str($this->repository_url)->endsWith('.git')) { $this->repository_url = str($this->repository_url)->beforeLast('.git')->value(); } + } catch (\Throwable $e) { return handleError($e, $this); } @@ -165,6 +193,9 @@ public function loadBranch() $this->branchFound = false; $this->getGitSource(); $this->getBranch(); + if (str($this->repository_url)->contains('tangled')) { + $this->git_branch = 'master'; + } $this->selectedBranch = $this->git_branch; } catch (\Throwable $e) { if ($this->rate_limit_remaining == 0) { @@ -191,6 +222,15 @@ private function getGitSource() $this->git_branch = 'main'; $this->base_directory = '/'; + // Validate repository URL before parsing + $validator = validator(['repository_url' => $this->repository_url], [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); @@ -234,6 +274,27 @@ public function submit() { try { $this->validate(); + + // Additional validation for git repository and branch + if ($this->git_source === 'other') { + // For 'other' sources, git_repository contains the full URL + $validator = validator(['git_repository' => $this->git_repository], [ + 'git_repository' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('git_repository')); + } + } + + $branchValidator = validator(['git_branch' => $this->git_branch], [ + 'git_branch' => ['required', 'string', new ValidGitBranch], + ]); + + if ($branchValidator->fails()) { + throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch')); + } + $destination_uuid = $this->query['destination']; $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; @@ -318,7 +379,7 @@ public function submit() $application->settings->is_static = $this->isStatic; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); if ($this->checkCoolifyConfig) { diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index ac07413d4..4ad3b9b29 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -57,13 +57,18 @@ class Select extends Component public function mount() { - $this->parameters = get_route_parameters(); - if (isDev()) { - $this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432'; + try { + $this->parameters = get_route_parameters(); + if (isDev()) { + $this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432'; + } + $projectUuid = data_get($this->parameters, 'project_uuid'); + $project = Project::whereUuid($projectUuid)->firstOrFail(); + $this->environments = $project->environments; + $this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name; + } catch (\Exception $e) { + return handleError($e, $this); } - $projectUuid = data_get($this->parameters, 'project_uuid'); - $this->environments = Project::whereUuid($projectUuid)->first()->environments; - $this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->first()->name; } public function render() diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index ebc9878dc..9cc4fbbe2 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -68,7 +68,7 @@ public function submit() 'source_type' => GithubApp::class, ]); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ 'name' => 'dockerfile-'.$application->uuid, 'fqdn' => $fqdn, diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index e7cff4f29..cdf95d2e4 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -15,6 +15,7 @@ class Create extends Component public function mount() { + $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); $server_id = request()->query('server_id'); @@ -80,7 +81,7 @@ public function mount() 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; - if ($oneClickServiceName === 'cloudflared') { + if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') { data_set($service_payload, 'connect_to_docker_network', true); } $service = Service::create($service_payload); @@ -96,7 +97,6 @@ public function mount() 'value' => $value, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), - 'is_build_time' => false, 'is_preview' => false, ]); } diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 8ac74e7de..2d69ceb12 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Service; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component { + use AuthorizesRequests; + public $currentRoute; public $project; @@ -30,6 +33,8 @@ public function getListeners() return [ "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', ]; } @@ -40,24 +45,30 @@ public function render() public function mount() { - $this->parameters = get_route_parameters(); - $this->currentRoute = request()->route()->getName(); - $this->query = request()->query(); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', request()->route('project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'uuid', 'name', 'project_id') - ->where('uuid', request()->route('environment_uuid')) - ->firstOrFail(); - $this->service = $environment->services()->whereUuid(request()->route('service_uuid'))->firstOrFail(); + try { + $this->parameters = get_route_parameters(); + $this->currentRoute = request()->route()->getName(); + $this->query = request()->query(); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', request()->route('environment_uuid')) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid(request()->route('service_uuid'))->firstOrFail(); - $this->project = $project; - $this->environment = $environment; - $this->applications = $this->service->applications->sort(); - $this->databases = $this->service->databases->sort(); + $this->authorize('view', $this->service); + + $this->project = $project; + $this->environment = $environment; + $this->applications = $this->service->applications->sort(); + $this->databases = $this->service->databases->sort(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function refreshServices() @@ -70,6 +81,7 @@ public function refreshServices() public function restartApplication($id) { try { + $this->authorize('update', $this->service); $application = $this->service->applications->find($id); if ($application) { $application->restart(); @@ -83,6 +95,7 @@ public function restartApplication($id) public function restartDatabase($id) { try { + $this->authorize('update', $this->service); $database = $this->service->databases->find($id); if ($database) { $database->restart(); diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index 0af757c8c..4bcf866d3 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -6,6 +6,7 @@ use App\Actions\Database\StopDatabaseProxy; use App\Models\InstanceSettings; use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -13,6 +14,8 @@ class Database extends Component { + use AuthorizesRequests; + public ServiceDatabase $database; public ?string $db_url_public = null; @@ -21,16 +24,30 @@ class Database extends Component public $parameters; + public ?string $humanName = null; + + public ?string $description = null; + + public ?string $image = null; + + public bool $excludeFromStatus = false; + + public ?int $publicPort = null; + + public bool $isPublic = false; + + public bool $isLogDrainEnabled = false; + protected $listeners = ['refreshFileStorages']; protected $rules = [ - 'database.human_name' => 'nullable', - 'database.description' => 'nullable', - 'database.image' => 'required', - 'database.exclude_from_status' => 'required|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_public' => 'required|boolean', - 'database.is_log_drain_enabled' => 'required|boolean', + 'humanName' => 'nullable', + 'description' => 'nullable', + 'image' => 'required', + 'excludeFromStatus' => 'required|boolean', + 'publicPort' => 'nullable|integer', + 'isPublic' => 'required|boolean', + 'isLogDrainEnabled' => 'required|boolean', ]; public function render() @@ -40,24 +57,53 @@ public function render() public function mount() { - $this->parameters = get_route_parameters(); - if ($this->database->is_public) { - $this->db_url_public = $this->database->getServiceDatabaseUrl(); + try { + $this->parameters = get_route_parameters(); + $this->authorize('view', $this->database); + if ($this->database->is_public) { + $this->db_url_public = $this->database->getServiceDatabaseUrl(); + } + $this->refreshFileStorages(); + $this->syncData(false); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->database->human_name = $this->humanName; + $this->database->description = $this->description; + $this->database->image = $this->image; + $this->database->exclude_from_status = $this->excludeFromStatus; + $this->database->public_port = $this->publicPort; + $this->database->is_public = $this->isPublic; + $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; + } else { + $this->humanName = $this->database->human_name; + $this->description = $this->database->description; + $this->image = $this->database->image; + $this->excludeFromStatus = $this->database->exclude_from_status ?? false; + $this->publicPort = $this->database->public_port; + $this->isPublic = $this->database->is_public ?? false; + $this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false; } - $this->refreshFileStorages(); } public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - try { + $this->authorize('delete', $this->database); + + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + $this->database->delete(); $this->dispatch('success', 'Database deleted.'); @@ -69,24 +115,35 @@ public function delete($password) public function instantSaveExclude() { - $this->submit(); + try { + $this->authorize('update', $this->database); + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function instantSaveLogDrain() { - if (! $this->database->service->destination->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + try { + $this->authorize('update', $this->database); + if (! $this->database->service->destination->server->isLogDrainEnabled()) { + $this->isLogDrainEnabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - return; + return; + } + $this->submit(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->submit(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } public function convertToApplication() { try { + $this->authorize('update', $this->database); $service = $this->database->service; $serviceDatabase = $this->database; @@ -122,28 +179,35 @@ public function convertToApplication() public function instantSave() { - if ($this->database->is_public && ! $this->database->public_port) { - $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; - - return; - } - if ($this->database->is_public) { - if (! str($this->database->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->database->is_public = false; + try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); + $this->isPublic = false; return; } - StartDatabaseProxy::run($this->database); - $this->db_url_public = $this->database->getServiceDatabaseUrl(); - $this->dispatch('success', 'Database is now publicly accessible.'); - } else { - StopDatabaseProxy::run($this->database); - $this->db_url_public = null; - $this->dispatch('success', 'Database is no longer publicly accessible.'); + $this->syncData(true); + if ($this->database->is_public) { + if (! str($this->database->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + $this->database->is_public = false; + + return; + } + StartDatabaseProxy::run($this->database); + $this->db_url_public = $this->database->getServiceDatabaseUrl(); + $this->dispatch('success', 'Database is now publicly accessible.'); + } else { + StopDatabaseProxy::run($this->database); + $this->db_url_public = null; + $this->dispatch('success', 'Database is no longer publicly accessible.'); + } + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->submit(); } public function refreshFileStorages() @@ -154,11 +218,16 @@ public function refreshFileStorages() public function submit() { try { + $this->authorize('update', $this->database); $this->validate(); + $this->syncData(true); $this->database->save(); + $this->database->refresh(); + $this->syncData(false); updateCompose($this->database); $this->dispatch('success', 'Database saved.'); - } catch (\Throwable) { + } catch (\Throwable $e) { + return handleError($e, $this); } finally { $this->dispatch('generateDockerCompose'); } diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index b5f208941..32cf72067 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -11,6 +11,12 @@ class EditCompose extends Component public $serviceId; + public ?string $dockerComposeRaw = null; + + public ?string $dockerCompose = null; + + public bool $isContainerLabelEscapeEnabled = false; + protected $listeners = [ 'refreshEnvs', 'envsUpdated', @@ -18,30 +24,45 @@ class EditCompose extends Component ]; protected $rules = [ - 'service.docker_compose_raw' => 'required', - 'service.docker_compose' => 'required', - 'service.is_container_label_escape_enabled' => 'required', + 'dockerComposeRaw' => 'required', + 'dockerCompose' => 'required', + 'isContainerLabelEscapeEnabled' => 'required', ]; public function envsUpdated() { - $this->dispatch('saveCompose', $this->service->docker_compose_raw); + $this->dispatch('saveCompose', $this->dockerComposeRaw); $this->refreshEnvs(); } public function refreshEnvs() { $this->service = Service::ownedByCurrentTeam()->find($this->serviceId); + $this->syncData(false); } public function mount() { $this->service = Service::ownedByCurrentTeam()->find($this->serviceId); + $this->syncData(false); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->service->docker_compose_raw = $this->dockerComposeRaw; + $this->service->docker_compose = $this->dockerCompose; + $this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled; + } else { + $this->dockerComposeRaw = $this->service->docker_compose_raw; + $this->dockerCompose = $this->service->docker_compose; + $this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false; + } } public function validateCompose() { - $isValid = validateComposeFile($this->service->docker_compose_raw, $this->service->server_id); + $isValid = validateComposeFile($this->dockerComposeRaw, $this->service->server_id); if ($isValid !== 'OK') { $this->dispatch('error', "Invalid docker-compose file.\n$isValid"); } else { @@ -52,16 +73,17 @@ public function validateCompose() public function saveEditedCompose() { $this->dispatch('info', 'Saving new docker compose...'); - $this->dispatch('saveCompose', $this->service->docker_compose_raw); + $this->dispatch('saveCompose', $this->dockerComposeRaw); $this->dispatch('refreshStorages'); } public function instantSave() { $this->validate([ - 'service.is_container_label_escape_enabled' => 'required', + 'isContainerLabelEscapeEnabled' => 'required', ]); - $this->service->save(['is_container_label_escape_enabled' => $this->service->is_container_label_escape_enabled]); + $this->syncData(true); + $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]); $this->dispatch('success', 'Service updated successfully'); } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index b7f73159e..43d885238 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -2,55 +2,101 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\ServiceApplication; use Livewire\Component; use Spatie\Url\Url; class EditDomain extends Component { + use SynchronizesModelData; public $applicationId; public ServiceApplication $application; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + + public ?string $fqdn = null; + protected $rules = [ - 'application.fqdn' => 'nullable', - 'application.required_fqdn' => 'required|boolean', + 'fqdn' => 'nullable', ]; public function mount() { - $this->application = ServiceApplication::find($this->applicationId); + $this->application = ServiceApplication::query()->findOrFail($this->applicationId); + $this->authorize('view', $this->application); + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'fqdn' => 'application.fqdn', + ]; + } + + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); } public function submit() { try { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $this->authorize('update', $this->application); + $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); + $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); + $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); - $this->application->fqdn = $this->application->fqdn->unique()->implode(','); - $warning = sslipDomainWarning($this->application->fqdn); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - check_domain_usage(resource: $this->application); + // Sync to model for domain conflict check + $this->syncToModel(); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->validate(); $this->application->save(); + $this->application->refresh(); + $this->syncData(false); updateCompose($this->application); if (str($this->application->fqdn)->contains(',')) { $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); } $this->application->service->parse(); $this->dispatch('refresh'); + $this->dispatch('refreshServices'); $this->dispatch('configurationChanged'); } catch (\Throwable $e) { $originalFqdn = $this->application->getOriginal('fqdn'); if ($originalFqdn !== $this->application->fqdn) { $this->application->fqdn = $originalFqdn; + $this->syncData(false); } return handleError($e, $this); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 5b88c15eb..40539b13e 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\Application; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; @@ -15,12 +16,15 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; class FileStorage extends Component { + use AuthorizesRequests, SynchronizesModelData; + public LocalFileVolume $fileStorage; public ServiceApplication|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase|Application $resource; @@ -31,12 +35,18 @@ class FileStorage extends Component public bool $permanently_delete = true; + public bool $isReadOnly = false; + + public ?string $content = null; + + public bool $isBasedOnGit = false; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', 'fileStorage.mount_path' => 'required', - 'fileStorage.content' => 'nullable', - 'fileStorage.is_based_on_git' => 'required|boolean', + 'content' => 'nullable', + 'isBasedOnGit' => 'required|boolean', ]; public function mount() @@ -49,11 +59,24 @@ public function mount() $this->workdir = null; $this->fs_path = $this->fileStorage->fs_path; } + + $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'content' => 'fileStorage.content', + 'isBasedOnGit' => 'fileStorage.is_based_on_git', + ]; } public function convertToDirectory() { try { + $this->authorize('update', $this->resource); + $this->fileStorage->deleteStorageOnServer(); $this->fileStorage->is_directory = true; $this->fileStorage->content = null; @@ -70,7 +93,10 @@ public function convertToDirectory() public function loadStorageOnServer() { try { + $this->authorize('update', $this->resource); + $this->fileStorage->loadStorageOnServer(); + $this->syncFromModel(); $this->dispatch('success', 'File storage loaded from server.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -82,6 +108,8 @@ public function loadStorageOnServer() public function convertToFile() { try { + $this->authorize('update', $this->resource); + $this->fileStorage->deleteStorageOnServer(); $this->fileStorage->is_directory = false; $this->fileStorage->content = null; @@ -99,6 +127,8 @@ public function convertToFile() public function delete($password) { + $this->authorize('update', $this->resource); + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); @@ -127,18 +157,22 @@ public function delete($password) public function submit() { + $this->authorize('update', $this->resource); + $original = $this->fileStorage->getOriginal(); try { $this->validate(); if ($this->fileStorage->is_directory) { - $this->fileStorage->content = null; + $this->content = null; } + $this->syncToModel(); $this->fileStorage->save(); $this->fileStorage->saveStorageOnServer(); $this->dispatch('success', 'File updated.'); } catch (\Throwable $e) { $this->fileStorage->setRawAttributes($original); $this->fileStorage->save(); + $this->syncFromModel(); return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 153b10fc8..3492da324 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -6,7 +6,6 @@ use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Enums\ProcessStatus; -use App\Events\ServiceStatusChanged; use App\Models\Service; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -64,7 +63,7 @@ public function serviceChecked() $this->service->databases->each(function ($database) { $database->refresh(); }); - if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { + if (is_null($this->service->config_hash)) { $this->service->isConfigurationChanged(true); } $this->dispatch('configurationChanged'); @@ -96,7 +95,7 @@ public function checkDeployments() public function start() { $activity = StartService::run($this->service, pullLatestImages: true); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $this->dispatch('activityMonitor', $activity->id); } public function forceDeploy() @@ -112,7 +111,7 @@ public function forceDeploy() $activity->save(); } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $this->dispatch('activityMonitor', $activity->id); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); } @@ -121,7 +120,6 @@ public function forceDeploy() public function stop() { try { - $this->dispatch('info', 'Gracefully stopping service, it could take a while depending on the service.'); StopService::dispatch($this->service, false, $this->docker_cleanup); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -137,7 +135,7 @@ public function restart() return; } $activity = StartService::run($this->service, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $this->dispatch('activityMonitor', $activity->id); } public function pullAndRestartEvent() @@ -149,7 +147,7 @@ public function pullAndRestartEvent() return; } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $this->dispatch('activityMonitor', $activity->id); } public function render() diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 39f4e106d..8d37d3e31 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -5,11 +5,14 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public ?Service $service = null; public ?ServiceApplication $serviceApplication = null; @@ -36,6 +39,7 @@ public function mount() if (! $this->service) { return redirect()->route('dashboard'); } + $this->authorize('view', $this->service); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { $this->serviceApplication = $service; @@ -52,7 +56,12 @@ public function mount() public function generateDockerCompose() { - $this->service->parse(); + try { + $this->authorize('update', $this->service); + $this->service->parse(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 64f7ab95c..20358218f 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -2,8 +2,10 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\InstanceSettings; use App\Models\ServiceApplication; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -12,6 +14,9 @@ class ServiceApplicationView extends Component { + use AuthorizesRequests; + use SynchronizesModelData; + public ServiceApplication $application; public $parameters; @@ -20,46 +25,81 @@ class ServiceApplicationView extends Component public $delete_volumes = true; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + + public ?string $humanName = null; + + public ?string $description = null; + + public ?string $fqdn = null; + + public ?string $image = null; + + public bool $excludeFromStatus = false; + + public bool $isLogDrainEnabled = false; + + public bool $isGzipEnabled = false; + + public bool $isStripprefixEnabled = false; + protected $rules = [ - 'application.human_name' => 'nullable', - 'application.description' => 'nullable', - 'application.fqdn' => 'nullable', - 'application.image' => 'string|nullable', - 'application.exclude_from_status' => 'required|boolean', + 'humanName' => 'nullable', + 'description' => 'nullable', + 'fqdn' => 'nullable', + 'image' => 'string|nullable', + 'excludeFromStatus' => 'required|boolean', 'application.required_fqdn' => 'required|boolean', - 'application.is_log_drain_enabled' => 'nullable|boolean', - 'application.is_gzip_enabled' => 'nullable|boolean', - 'application.is_stripprefix_enabled' => 'nullable|boolean', + 'isLogDrainEnabled' => 'nullable|boolean', + 'isGzipEnabled' => 'nullable|boolean', + 'isStripprefixEnabled' => 'nullable|boolean', ]; public function instantSave() { - $this->submit(); + try { + $this->authorize('update', $this->application); + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function instantSaveAdvanced() { - if (! $this->application->service->destination->server->isLogDrainEnabled()) { - $this->application->is_log_drain_enabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + try { + $this->authorize('update', $this->application); + if (! $this->application->service->destination->server->isLogDrainEnabled()) { + $this->isLogDrainEnabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - return; + return; + } + $this->syncToModel(); + $this->application->save(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->application->save(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - try { + $this->authorize('delete', $this->application); + + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + $this->application->delete(); $this->dispatch('success', 'Application deleted.'); @@ -71,12 +111,33 @@ public function delete($password) public function mount() { - $this->parameters = get_route_parameters(); + try { + $this->parameters = get_route_parameters(); + $this->authorize('view', $this->application); + $this->syncFromModel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + protected function getModelBindings(): array + { + return [ + 'humanName' => 'application.human_name', + 'description' => 'application.description', + 'fqdn' => 'application.fqdn', + 'image' => 'application.image', + 'excludeFromStatus' => 'application.exclude_from_status', + 'isLogDrainEnabled' => 'application.is_log_drain_enabled', + 'isGzipEnabled' => 'application.is_gzip_enabled', + 'isStripprefixEnabled' => 'application.is_stripprefix_enabled', + ]; } public function convertToDatabase() { try { + $this->authorize('update', $this->application); $service = $this->application->service; $serviceApplication = $this->application; @@ -108,24 +169,50 @@ public function convertToDatabase() } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $this->authorize('update', $this->application); + $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); + $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); + $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); - $this->application->fqdn = $this->application->fqdn->unique()->implode(','); - $warning = sslipDomainWarning($this->application->fqdn); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - check_domain_usage(resource: $this->application); + // Sync to model for domain conflict check + $this->syncToModel(); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->validate(); $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); updateCompose($this->application); if (str($this->application->fqdn)->contains(',')) { $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); @@ -137,6 +224,7 @@ public function submit() $originalFqdn = $this->application->getOriginal('fqdn'); if ($originalFqdn !== $this->application->fqdn) { $this->application->fqdn = $originalFqdn; + $this->syncFromModel(); } return handleError($e, $this); diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index a67bd9210..85cd21a7f 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Service; use App\Models\Service; +use App\Support\ValidationPatterns; use Illuminate\Support\Collection; use Livewire\Component; @@ -14,18 +15,79 @@ class StackForm extends Component protected $listeners = ['saveCompose']; - public $rules = [ - 'service.docker_compose_raw' => 'required', - 'service.docker_compose' => 'required', - 'service.name' => 'required', - 'service.description' => 'nullable', - 'service.connect_to_docker_network' => 'nullable', - ]; + // Explicit properties + public string $name; + + public ?string $description = null; + + public string $dockerComposeRaw; + + public string $dockerCompose; + + public ?bool $connectToDockerNetwork = null; + + protected function rules(): array + { + $baseRules = [ + 'dockerComposeRaw' => 'required', + 'dockerCompose' => 'required', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'connectToDockerNetwork' => 'nullable', + ]; + + // Add dynamic field rules + foreach ($this->fields ?? collect() as $key => $field) { + $rules = data_get($field, 'rules', 'nullable'); + $baseRules["fields.$key.value"] = $rules; + } + + return $baseRules; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.', + 'dockerCompose.required' => 'The Docker Compose field is required.', + ] + ); + } public $validationAttributes = []; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->service->name = $this->name; + $this->service->description = $this->description; + $this->service->docker_compose_raw = $this->dockerComposeRaw; + $this->service->docker_compose = $this->dockerCompose; + $this->service->connect_to_docker_network = $this->connectToDockerNetwork; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->service->name; + $this->description = $this->service->description; + $this->dockerComposeRaw = $this->service->docker_compose_raw; + $this->dockerCompose = $this->service->docker_compose; + $this->connectToDockerNetwork = $this->service->connect_to_docker_network; + } + } + public function mount() { + $this->syncData(false); $this->fields = collect([]); $extraFields = $this->service->extraFields(); foreach ($extraFields as $serviceName => $fields) { @@ -45,7 +107,6 @@ public function mount() 'customHelper' => $customHelper, ]); - $this->rules["fields.$key.value"] = $rules; $this->validationAttributes["fields.$key.value"] = $fieldKey; } } @@ -62,12 +123,13 @@ public function mount() public function saveCompose($raw) { - $this->service->docker_compose_raw = $raw; + $this->dockerComposeRaw = $raw; $this->submit(notify: true); } public function instantSave() { + $this->syncData(true); $this->service->save(); $this->dispatch('success', 'Service settings saved.'); } @@ -76,6 +138,11 @@ public function submit($notify = true) { try { $this->validate(); + $this->syncData(true); + + // Validate for command injection BEFORE saving to database + validateDockerComposeForInjection($this->service->docker_compose_raw); + $this->service->save(); $this->service->saveExtraFields($this->fields); $this->service->parse(); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 4b64a8b5e..db171db24 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -3,14 +3,33 @@ namespace App\Livewire\Project\Service; use App\Models\LocalPersistentVolume; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Storage extends Component { + use AuthorizesRequests; + public $resource; public $fileStorage; + public $isSwarm = false; + + public string $name = ''; + + public string $mount_path = ''; + + public ?string $host_path = null; + + public string $file_storage_path = ''; + + public ?string $file_storage_content = null; + + public string $file_storage_directory_source = ''; + + public string $file_storage_directory_destination = ''; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -24,6 +43,18 @@ public function getListeners() public function mount() { + if (str($this->resource->getMorphClass())->contains('Standalone')) { + $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; + } else { + $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; + } + + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->destination->server->isSwarm()) { + $this->isSwarm = true; + } + } + $this->refreshStorages(); } @@ -36,28 +67,151 @@ public function refreshStoragesFromEvent() public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->dispatch('$refresh'); + $this->resource->refresh(); } - public function addNewVolume($data) + public function getFilesProperty() + { + return $this->fileStorage->where('is_directory', false); + } + + public function getDirectoriesProperty() + { + return $this->fileStorage->where('is_directory', true); + } + + public function getVolumeCountProperty() + { + return $this->resource->persistentStorages()->count(); + } + + public function getFileCountProperty() + { + return $this->files->count(); + } + + public function getDirectoryCountProperty() + { + return $this->directories->count(); + } + + public function submitPersistentVolume() { try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'name' => 'required|string', + 'mount_path' => 'required|string', + 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable', + ]); + + $name = $this->resource->uuid.'-'.$this->name; + LocalPersistentVolume::create([ - 'name' => $data['name'], - 'mount_path' => $data['mount_path'], - 'host_path' => $data['host_path'], + 'name' => $name, + 'mount_path' => $this->mount_path, + 'host_path' => $this->host_path, 'resource_id' => $this->resource->id, 'resource_type' => $this->resource->getMorphClass(), ]); $this->resource->refresh(); - $this->dispatch('success', 'Storage added successfully'); - $this->dispatch('clearAddStorage'); - $this->dispatch('refreshStorages'); + $this->dispatch('success', 'Volume added successfully'); + $this->dispatch('closeStorageModal', 'volume'); + $this->clearForm(); + $this->refreshStorages(); } catch (\Throwable $e) { return handleError($e, $this); } } + public function submitFileStorage() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'file_storage_path' => 'required|string', + 'file_storage_content' => 'nullable|string', + ]); + + $this->file_storage_path = trim($this->file_storage_path); + $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); + + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; + } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { + $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; + } else { + throw new \Exception('No valid resource type for file mount storage type!'); + } + + \App\Models\LocalFileVolume::create([ + 'fs_path' => $fs_path, + 'mount_path' => $this->file_storage_path, + 'content' => $this->file_storage_content, + 'is_directory' => false, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'File mount added successfully'); + $this->dispatch('closeStorageModal', 'file'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submitFileStorageDirectory() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'file_storage_directory_source' => 'required|string', + 'file_storage_directory_destination' => 'required|string', + ]); + + $this->file_storage_directory_source = trim($this->file_storage_directory_source); + $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value(); + $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); + $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); + + \App\Models\LocalFileVolume::create([ + 'fs_path' => $this->file_storage_directory_source, + 'mount_path' => $this->file_storage_directory_destination, + 'is_directory' => true, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'Directory mount added successfully'); + $this->dispatch('closeStorageModal', 'directory'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function clearForm() + { + $this->name = ''; + $this->mount_path = ''; + $this->host_path = null; + $this->file_storage_path = ''; + $this->file_storage_content = null; + $this->file_storage_directory_destination = ''; + + if (str($this->resource->getMorphClass())->contains('Standalone')) { + $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; + } else { + $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; + } + } + public function render() { return view('livewire.project.service.storage'); diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index ab9f3785d..ce9ce7780 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -20,7 +20,15 @@ class ConfigurationChecker extends Component public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; - protected $listeners = ['configurationChanged']; + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged', + 'configurationChanged' => 'configurationChanged', + ]; + } public function mount() { diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 7da48f9fb..0ed1347f8 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -7,6 +7,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; @@ -14,6 +15,8 @@ class Danger extends Component { + use AuthorizesRequests; + public $resource; public $resourceName; @@ -34,6 +37,8 @@ class Danger extends Component public string $resourceDomain = ''; + public bool $canDelete = false; + public function mount() { $parameters = get_route_parameters(); @@ -77,6 +82,13 @@ public function mount() 'service-database' => $this->resource->name ?? 'Service Database', default => 'Unknown Resource', }; + + // Check if user can delete this resource + try { + $this->canDelete = auth()->user()->can('delete', $this->resource); + } catch (\Exception $e) { + $this->canDelete = false; + } } public function delete($password) @@ -96,13 +108,14 @@ public function delete($password) } try { + $this->authorize('delete', $this->resource); $this->resource->delete(); DeleteResourceJob::dispatch( $this->resource, - $this->delete_configurations, $this->delete_volumes, - $this->docker_cleanup, - $this->delete_connected_networks + $this->delete_connected_networks, + $this->delete_configurations, + $this->docker_cleanup ); return redirect()->route('project.resource.index', [ diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 9d5331ff6..40291d2b0 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -26,6 +26,8 @@ public function getListeners() return [ "echo-private:team.{$teamId},ApplicationStatusChanged" => 'loadData', + "echo-private:team.{$teamId},ServiceStatusChanged" => 'mount', + 'refresh' => 'mount', ]; } @@ -114,6 +116,7 @@ public function promote(int $network_id, int $server_id) $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); $this->refreshServers(); + $this->resource->refresh(); } public function refreshServers() @@ -126,7 +129,7 @@ public function refreshServers() public function addServer(int $network_id, int $server_id) { $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); - $this->loadData(); + $this->dispatch('refresh'); } public function removeServer(int $network_id, int $server_id, $password) @@ -141,7 +144,7 @@ public function removeServer(int $network_id, int $server_id, $password) } if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { - $this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); + $this->dispatch('error', 'You are trying to remove the main server.'); return; } @@ -149,6 +152,7 @@ public function removeServer(int $network_id, int $server_id, $password) StopApplicationOneServer::run($this->resource, $server); $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->loadData(); + $this->dispatch('refresh'); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 0dbf0f957..5f5e12e0a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,10 +2,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Traits\EnvironmentVariableAnalyzer; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Add extends Component { + use AuthorizesRequests, EnvironmentVariableAnalyzer; + public $parameters; public bool $shared = false; @@ -16,33 +20,40 @@ class Add extends Component public ?string $value = null; - public bool $is_build_time = false; - public bool $is_multiline = false; public bool $is_literal = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; + + public array $problematicVariables = []; + protected $listeners = ['clearAddEnv' => 'clear']; protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'is_build_time' => 'required|boolean', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', ]; protected $validationAttributes = [ 'key' => 'key', 'value' => 'value', - 'is_build_time' => 'build', 'is_multiline' => 'multiline', 'is_literal' => 'literal', + 'is_runtime' => 'runtime', + 'is_buildtime' => 'buildtime', ]; public function mount() { $this->parameters = get_route_parameters(); + $this->problematicVariables = self::getProblematicVariablesForFrontend(); } public function submit() @@ -51,9 +62,10 @@ public function submit() $this->dispatch('saveKey', [ 'key' => $this->key, 'value' => $this->value, - 'is_build_time' => $this->is_build_time, 'is_multiline' => $this->is_multiline, 'is_literal' => $this->is_literal, + 'is_runtime' => $this->is_runtime, + 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, ]); $this->clear(); @@ -63,8 +75,9 @@ public function clear() { $this->key = ''; $this->value = ''; - $this->is_build_time = false; $this->is_multiline = false; $this->is_literal = false; + $this->is_runtime = true; + $this->is_buildtime = true; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 699dca187..07938d9d0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -4,11 +4,12 @@ use App\Models\EnvironmentVariable; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class All extends Component { - use EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableProtection; public $resource; @@ -24,6 +25,8 @@ class All extends Component public bool $is_env_sorting_enabled = false; + public bool $use_build_secrets = false; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -33,43 +36,54 @@ class All extends Component public function mount() { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); + $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); $resourceWithPreviews = [\App\Models\Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $this->showPreview = true; } - $this->sortEnvironmentVariables(); + $this->getDevView(); } public function instantSave() { - $this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; - $this->resource->settings->save(); - $this->sortEnvironmentVariables(); - $this->dispatch('success', 'Environment variable settings updated.'); + try { + $this->authorize('manageEnvironment', $this->resource); + + $this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; + $this->resource->settings->use_build_secrets = $this->use_build_secrets; + $this->resource->settings->save(); + $this->getDevView(); + $this->dispatch('success', 'Environment variable settings updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } - public function sortEnvironmentVariables() + public function getEnvironmentVariablesProperty() { if ($this->is_env_sorting_enabled === false) { - if ($this->resource->environment_variables) { - $this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values(); - } - - if ($this->resource->environment_variables_preview) { - $this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values(); - } + return $this->resource->environment_variables()->orderBy('order')->get(); } - $this->getDevView(); + return $this->resource->environment_variables; + } + + public function getEnvironmentVariablesPreviewProperty() + { + if ($this->is_env_sorting_enabled === false) { + return $this->resource->environment_variables_preview()->orderBy('order')->get(); + } + + return $this->resource->environment_variables_preview; } public function getDevView() { - $this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables); + $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); if ($this->showPreview) { - $this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview); + $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview); } } @@ -90,12 +104,13 @@ private function formatEnvironmentVariables($variables) public function switch() { $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->sortEnvironmentVariables(); + $this->getDevView(); } public function submit($data = null) { try { + $this->authorize('manageEnvironment', $this->resource); if ($data === null) { $this->handleBulkSubmit(); } else { @@ -103,7 +118,7 @@ public function submit($data = null) } $this->updateOrder(); - $this->sortEnvironmentVariables(); + $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); } finally { @@ -178,16 +193,6 @@ private function handleBulkSubmit() } } - // Debug information - \Log::info('Environment variables update status', [ - 'deletedCount' => $deletedCount, - 'updatedCount' => $updatedCount, - 'deletedPreviewCount' => $deletedPreviewCount ?? 0, - 'updatedPreviewCount' => $updatedPreviewCount ?? 0, - 'changesMade' => $changesMade, - 'errorOccurred' => $errorOccurred, - ]); - // Only show success message if changes were actually made and no errors occurred if ($changesMade && ! $errorOccurred) { $this->dispatch('success', 'Environment variables updated.'); @@ -207,6 +212,12 @@ private function handleSingleSubmit($data) $environment = $this->createEnvironmentVariable($data); $environment->order = $maxOrder + 1; $environment->save(); + + // Clear computed property cache to force refresh + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); + + $this->dispatch('success', 'Environment variable added.'); } private function createEnvironmentVariable($data) @@ -214,9 +225,10 @@ private function createEnvironmentVariable($data) $environment = new EnvironmentVariable; $environment->key = $data['key']; $environment->value = $data['value']; - $environment->is_build_time = $data['is_build_time'] ?? false; $environment->is_multiline = $data['is_multiline'] ?? false; $environment->is_literal = $data['is_literal'] ?? false; + $environment->is_runtime = $data['is_runtime'] ?? true; + $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); @@ -259,7 +271,7 @@ private function updateOrCreateVariables($isPreview, $variables) { $count = 0; foreach ($variables as $key => $value) { - if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) { + if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; @@ -278,7 +290,6 @@ private function updateOrCreateVariables($isPreview, $variables) $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; - $environment->is_build_time = false; $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; @@ -295,7 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables) public function refreshEnvs() { $this->resource->refresh(); - $this->sortEnvironmentVariables(); + // Clear computed property cache to force refresh + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); $this->getDevView(); } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 535ac6c67..3b8d244cc 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -4,12 +4,14 @@ use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\SharedEnvironmentVariable; +use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { - use EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; public $parameters; @@ -31,20 +33,24 @@ class Show extends Component public bool $is_shared = false; - public bool $is_build_time = false; - public bool $is_multiline = false; public bool $is_literal = false; public bool $is_shown_once = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; + public bool $is_required = false; public bool $is_really_required = false; public bool $is_redis_credential = false; + public array $problematicVariables = []; + protected $listeners = [ 'refreshEnvs' => 'refresh', 'refresh', @@ -54,10 +60,11 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'is_build_time' => 'required|boolean', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', 'real_value' => 'nullable', 'is_required' => 'required|boolean', ]; @@ -73,6 +80,12 @@ public function mount() if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) { $this->is_redis_credential = true; } + $this->problematicVariables = self::getProblematicVariablesForFrontend(); + } + + public function getResourceProperty() + { + return $this->env->resourceable ?? $this->env; } public function refresh() @@ -95,8 +108,9 @@ public function syncData(bool $toModel = false) ]); } else { $this->validate(); - $this->env->is_build_time = $this->is_build_time; $this->env->is_required = $this->is_required; + $this->env->is_runtime = $this->is_runtime; + $this->env->is_buildtime = $this->is_buildtime; $this->env->is_shared = $this->is_shared; } $this->env->key = $this->key; @@ -108,10 +122,11 @@ public function syncData(bool $toModel = false) } else { $this->key = $this->env->key; $this->value = $this->env->value; - $this->is_build_time = $this->env->is_build_time ?? false; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; + $this->is_runtime = $this->env->is_runtime ?? true; + $this->is_buildtime = $this->env->is_buildtime ?? true; $this->is_required = $this->env->is_required ?? false; $this->is_really_required = $this->env->is_really_required ?? false; $this->is_shared = $this->env->is_shared ?? false; @@ -122,7 +137,7 @@ public function syncData(bool $toModel = false) public function checkEnvs() { $this->isDisabled = false; - if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL')) { + if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) { $this->isDisabled = true; } if ($this->env->is_shown_once) { @@ -133,13 +148,12 @@ public function checkEnvs() public function serialize() { data_forget($this->env, 'real_value'); - if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) { - data_forget($this->env, 'is_build_time'); - } } public function lock() { + $this->authorize('update', $this->env); + $this->env->is_shown_once = true; if ($this->isSharedVariable) { unset($this->env->is_required); @@ -158,6 +172,8 @@ public function instantSave() public function submit() { try { + $this->authorize('update', $this->env); + if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) { $oldValue = $this->env->getOriginal('value'); $this->value = $oldValue; @@ -170,6 +186,7 @@ public function submit() $this->syncData(true); $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); + $this->dispatch('configurationChanged'); } catch (\Exception $e) { return handleError($e); } @@ -178,9 +195,11 @@ public function submit() public function delete() { try { + $this->authorize('delete', $this->env); + // Check if the variable is used in Docker Compose - if ($this->type === 'service' || $this->type === 'application' && $this->env->resource()?->docker_compose) { - [$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($this->env->key, $this->env->resource()?->docker_compose); + if ($this->type === 'service' || $this->type === 'application' && $this->env->resourceable?->docker_compose) { + [$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($this->env->key, $this->env->resourceable?->docker_compose); if ($isUsed) { $this->dispatch('error', "Cannot delete environment variable '{$this->env->key}'

Please remove it from the Docker Compose file first."); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index df69a2250..02062e1f7 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -13,8 +13,6 @@ class ExecuteContainerCommand extends Component { public $selected_container = 'default'; - public $container; - public Collection $containers; public $parameters; @@ -23,11 +21,9 @@ class ExecuteContainerCommand extends Component public string $type; - public Server $server; - public Collection $servers; - public bool $hasShell = true; + public bool $isConnecting = false; protected $rules = [ 'server' => 'required', @@ -35,20 +31,8 @@ class ExecuteContainerCommand extends Component 'command' => 'required', ]; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } $this->parameters = get_route_parameters(); $this->containers = collect(); $this->servers = collect(); @@ -85,8 +69,9 @@ public function mount() } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; $this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail(); - $this->server = $this->resource; + $this->servers = $this->servers->push($this->resource); } + $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); } public function loadContainers() @@ -104,7 +89,7 @@ public function loadContainers() } foreach ($containers as $container) { // if container state is running - if (data_get($container, 'State') === 'running') { + if (data_get($container, 'State') === 'running' && $server->isTerminalEnabled()) { $payload = [ 'server' => $server, 'container' => $container, @@ -113,7 +98,7 @@ public function loadContainers() } } } elseif (data_get($this->parameters, 'database_uuid')) { - if ($this->resource->isRunning()) { + if ($this->resource->isRunning() && $server->isTerminalEnabled()) { $this->containers = $this->containers->push([ 'server' => $server, 'container' => [ @@ -123,7 +108,7 @@ public function loadContainers() } } elseif (data_get($this->parameters, 'service_uuid')) { $this->resource->applications()->get()->each(function ($application) { - if ($application->isRunning()) { + if ($application->isRunning() && $this->resource->server->isTerminalEnabled()) { $this->containers->push([ 'server' => $this->resource->server, 'container' => [ @@ -144,26 +129,21 @@ public function loadContainers() }); } } - if ($this->containers->count() > 0) { - $this->container = $this->containers->first(); - } + + // Sort containers alphabetically by name + $this->containers = $this->containers->sortBy(function ($container) { + return data_get($container, 'container.Names'); + }); + if ($this->containers->count() === 1) { $this->selected_container = data_get($this->containers->first(), 'container.Names'); } } - private function checkShellAvailability(Server $server, string $container): bool + public function updatedSelectedContainer() { - $escapedContainer = escapeshellarg($container); - try { - instant_remote_process([ - "docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". - "docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", - ], $server); - - return true; - } catch (\Throwable) { - return false; + if ($this->selected_container !== 'default') { + $this->connectToContainer(); } } @@ -171,18 +151,23 @@ private function checkShellAvailability(Server $server, string $container): bool public function connectToServer() { try { - if ($this->server->isForceDisabled()) { + $server = $this->servers->first(); + if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } - $this->hasShell = true; $this->dispatch( 'send-terminal-command', false, - data_get($this->server, 'name'), - data_get($this->server, 'uuid') + data_get($server, 'name'), + data_get($server, 'uuid') ); + + // Dispatch a frontend event to ensure terminal gets focus after connection + $this->dispatch('terminal-should-focus'); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->isConnecting = false; } } @@ -228,19 +213,19 @@ public function connectToContainer() throw new \RuntimeException('Server ownership verification failed.'); } - $this->hasShell = $this->checkShellAvailability($server, data_get($container, 'container.Names')); - if (! $this->hasShell) { - return; - } - $this->dispatch( 'send-terminal-command', true, data_get($container, 'container.Names'), data_get($container, 'server.uuid') ); + + // Dispatch a frontend event to ensure terminal gets focus after connection + $this->dispatch('terminal-should-focus'); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->isConnecting = false; } } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 83162e36a..c8029761d 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,31 +2,90 @@ namespace App\Livewire\Project\Shared; +use App\Livewire\Concerns\SynchronizesModelData; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class HealthChecks extends Component { + use AuthorizesRequests; + use SynchronizesModelData; + public $resource; - protected $rules = [ - 'resource.health_check_enabled' => 'boolean', - 'resource.health_check_path' => 'string', - 'resource.health_check_port' => 'nullable|string', - 'resource.health_check_host' => 'string', - 'resource.health_check_method' => 'string', - 'resource.health_check_return_code' => 'integer', - 'resource.health_check_scheme' => 'string', - 'resource.health_check_response_text' => 'nullable|string', - 'resource.health_check_interval' => 'integer|min:1', - 'resource.health_check_timeout' => 'integer|min:1', - 'resource.health_check_retries' => 'integer|min:1', - 'resource.health_check_start_period' => 'integer', - 'resource.custom_healthcheck_found' => 'boolean', + // Explicit properties + public bool $healthCheckEnabled = false; + public string $healthCheckMethod; + + public string $healthCheckScheme; + + public string $healthCheckHost; + + public ?string $healthCheckPort = null; + + public string $healthCheckPath; + + public int $healthCheckReturnCode; + + public ?string $healthCheckResponseText = null; + + public int $healthCheckInterval; + + public int $healthCheckTimeout; + + public int $healthCheckRetries; + + public int $healthCheckStartPeriod; + + public bool $customHealthcheckFound = false; + + protected $rules = [ + 'healthCheckEnabled' => 'boolean', + 'healthCheckPath' => 'string', + 'healthCheckPort' => 'nullable|string', + 'healthCheckHost' => 'string', + 'healthCheckMethod' => 'string', + 'healthCheckReturnCode' => 'integer', + 'healthCheckScheme' => 'string', + 'healthCheckResponseText' => 'nullable|string', + 'healthCheckInterval' => 'integer|min:1', + 'healthCheckTimeout' => 'integer|min:1', + 'healthCheckRetries' => 'integer|min:1', + 'healthCheckStartPeriod' => 'integer', + 'customHealthcheckFound' => 'boolean', ]; + protected function getModelBindings(): array + { + return [ + 'healthCheckEnabled' => 'resource.health_check_enabled', + 'healthCheckMethod' => 'resource.health_check_method', + 'healthCheckScheme' => 'resource.health_check_scheme', + 'healthCheckHost' => 'resource.health_check_host', + 'healthCheckPort' => 'resource.health_check_port', + 'healthCheckPath' => 'resource.health_check_path', + 'healthCheckReturnCode' => 'resource.health_check_return_code', + 'healthCheckResponseText' => 'resource.health_check_response_text', + 'healthCheckInterval' => 'resource.health_check_interval', + 'healthCheckTimeout' => 'resource.health_check_timeout', + 'healthCheckRetries' => 'resource.health_check_retries', + 'healthCheckStartPeriod' => 'resource.health_check_start_period', + 'customHealthcheckFound' => 'resource.custom_healthcheck_found', + ]; + } + + public function mount() + { + $this->authorize('view', $this->resource); + $this->syncFromModel(); + } + public function instantSave() { + $this->authorize('update', $this->resource); + + $this->syncToModel(); $this->resource->save(); $this->dispatch('success', 'Health check updated.'); } @@ -34,7 +93,10 @@ public function instantSave() public function submit() { try { + $this->authorize('update', $this->resource); $this->validate(); + + $this->syncToModel(); $this->resource->save(); $this->dispatch('success', 'Health check updated.'); } catch (\Throwable $e) { @@ -42,6 +104,26 @@ public function submit() } } + public function toggleHealthcheck() + { + try { + $this->authorize('update', $this->resource); + $wasEnabled = $this->healthCheckEnabled; + $this->healthCheckEnabled = ! $this->healthCheckEnabled; + + $this->syncToModel(); + $this->resource->save(); + + if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) { + $this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.'); + } else { + $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.shared.health-checks'); diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index 2d760fae2..6c4aadd39 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -25,6 +25,8 @@ class Logs extends Component public Collection $containers; + public array $serverContainers = []; + public $container = []; public $parameters; @@ -37,6 +39,8 @@ class Logs extends Component public $cpu; + public bool $containersLoaded = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -46,25 +50,49 @@ public function getListeners() ]; } - public function loadContainers($server_id) + public function loadAllContainers() { try { - $server = $this->servers->firstWhere('id', $server_id); - if (! $server->isFunctional()) { - return; + foreach ($this->servers as $server) { + $this->serverContainers[$server->id] = $this->getContainersForServer($server); } + $this->containersLoaded = true; + } catch (\Exception $e) { + $this->containersLoaded = true; // Set to true to stop loading spinner + + return handleError($e, $this); + } + } + + private function getContainersForServer($server) + { + if (! $server->isFunctional()) { + return []; + } + + try { if ($server->isSwarm()) { $containers = collect([ [ + 'ID' => $this->resource->uuid, 'Names' => $this->resource->uuid.'_'.$this->resource->uuid, ], ]); + + return $containers->toArray(); } else { $containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true); + if ($containers && $containers->count() > 0) { + return $containers->sort()->toArray(); + } + + return []; } - $server->containers = $containers->sort(); } catch (\Exception $e) { - return handleError($e, $this); + // Log error but don't fail the entire operation + ray("Error loading containers for server {$server->name}: ".$e->getMessage()); + + return []; } } @@ -73,6 +101,7 @@ public function mount() try { $this->containers = collect(); $this->servers = collect(); + $this->serverContainers = []; $this->parameters = get_route_parameters(); $this->query = request()->query(); if (data_get($this->parameters, 'application_uuid')) { @@ -80,7 +109,8 @@ public function mount() $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); $this->status = $this->resource->status; if ($this->resource->destination->server->isFunctional()) { - $this->servers = $this->servers->push($this->resource->destination->server); + $server = $this->resource->destination->server; + $this->servers = $this->servers->push($server); } foreach ($this->resource->additional_servers as $server) { if ($server->isFunctional()) { @@ -96,7 +126,8 @@ public function mount() $this->resource = $resource; $this->status = $this->resource->status; if ($this->resource->destination->server->isFunctional()) { - $this->servers = $this->servers->push($this->resource->destination->server); + $server = $this->resource->destination->server; + $this->servers = $this->servers->push($server); } $this->container = $this->resource->uuid; $this->containers->push($this->container); @@ -110,7 +141,8 @@ public function mount() $this->containers->push(data_get($database, 'name').'-'.data_get($this->resource, 'uuid')); }); if ($this->resource->server->isFunctional()) { - $this->servers = $this->servers->push($this->resource->server); + $server = $this->resource->server; + $this->servers = $this->servers->push($server); } } $this->containers = $this->containers->sort(); diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index fdc35fc0f..e5b87b48c 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -8,7 +8,7 @@ class Metrics extends Component { public $resource; - public $chartId = 'container-cpu'; + public $chartId = 'metrics'; public $data; diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 608dfbf02..0b3840289 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -2,57 +2,114 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class ResourceLimits extends Component { + use AuthorizesRequests; + public $resource; + // Explicit properties + public ?string $limitsCpus = null; + + public ?string $limitsCpuset = null; + + public ?int $limitsCpuShares = null; + + public string $limitsMemory; + + public string $limitsMemorySwap; + + public int $limitsMemorySwappiness; + + public string $limitsMemoryReservation; + protected $rules = [ - 'resource.limits_memory' => 'required|string', - 'resource.limits_memory_swap' => 'required|string', - 'resource.limits_memory_swappiness' => 'required|integer|min:0|max:100', - 'resource.limits_memory_reservation' => 'required|string', - 'resource.limits_cpus' => 'nullable', - 'resource.limits_cpuset' => 'nullable', - 'resource.limits_cpu_shares' => 'nullable', + 'limitsMemory' => 'required|string', + 'limitsMemorySwap' => 'required|string', + 'limitsMemorySwappiness' => 'required|integer|min:0|max:100', + 'limitsMemoryReservation' => 'required|string', + 'limitsCpus' => 'nullable', + 'limitsCpuset' => 'nullable', + 'limitsCpuShares' => 'nullable', ]; protected $validationAttributes = [ - 'resource.limits_memory' => 'memory', - 'resource.limits_memory_swap' => 'swap', - 'resource.limits_memory_swappiness' => 'swappiness', - 'resource.limits_memory_reservation' => 'reservation', - 'resource.limits_cpus' => 'cpus', - 'resource.limits_cpuset' => 'cpuset', - 'resource.limits_cpu_shares' => 'cpu shares', + 'limitsMemory' => 'memory', + 'limitsMemorySwap' => 'swap', + 'limitsMemorySwappiness' => 'swappiness', + 'limitsMemoryReservation' => 'reservation', + 'limitsCpus' => 'cpus', + 'limitsCpuset' => 'cpuset', + 'limitsCpuShares' => 'cpu shares', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->resource->limits_cpus = $this->limitsCpus; + $this->resource->limits_cpuset = $this->limitsCpuset; + $this->resource->limits_cpu_shares = $this->limitsCpuShares; + $this->resource->limits_memory = $this->limitsMemory; + $this->resource->limits_memory_swap = $this->limitsMemorySwap; + $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness; + $this->resource->limits_memory_reservation = $this->limitsMemoryReservation; + } else { + // Sync FROM model (on load/refresh) + $this->limitsCpus = $this->resource->limits_cpus; + $this->limitsCpuset = $this->resource->limits_cpuset; + $this->limitsCpuShares = $this->resource->limits_cpu_shares; + $this->limitsMemory = $this->resource->limits_memory; + $this->limitsMemorySwap = $this->resource->limits_memory_swap; + $this->limitsMemorySwappiness = $this->resource->limits_memory_swappiness; + $this->limitsMemoryReservation = $this->resource->limits_memory_reservation; + } + } + + public function mount() + { + $this->syncData(false); + } + public function submit() { try { - if (! $this->resource->limits_memory) { - $this->resource->limits_memory = '0'; + $this->authorize('update', $this->resource); + + // Apply default values to properties + if (! $this->limitsMemory) { + $this->limitsMemory = '0'; } - if (! $this->resource->limits_memory_swap) { - $this->resource->limits_memory_swap = '0'; + if (! $this->limitsMemorySwap) { + $this->limitsMemorySwap = '0'; } - if (is_null($this->resource->limits_memory_swappiness)) { - $this->resource->limits_memory_swappiness = '60'; + if (is_null($this->limitsMemorySwappiness)) { + $this->limitsMemorySwappiness = 60; } - if (! $this->resource->limits_memory_reservation) { - $this->resource->limits_memory_reservation = '0'; + if (! $this->limitsMemoryReservation) { + $this->limitsMemoryReservation = '0'; } - if (! $this->resource->limits_cpus) { - $this->resource->limits_cpus = '0'; + if (! $this->limitsCpus) { + $this->limitsCpus = '0'; } - if ($this->resource->limits_cpuset === '') { - $this->resource->limits_cpuset = null; + if ($this->limitsCpuset === '') { + $this->limitsCpuset = null; } - if (is_null($this->resource->limits_cpu_shares)) { - $this->resource->limits_cpu_shares = 1024; + if (is_null($this->limitsCpuShares)) { + $this->limitsCpuShares = 1024; } + $this->validate(); + + $this->syncData(true); $this->resource->save(); $this->dispatch('success', 'Resource limits updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index e19f1272d..47b3534a2 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Service\StartService; @@ -12,11 +11,14 @@ use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class ResourceOperations extends Component { + use AuthorizesRequests; + public $resource; public $projectUuid; @@ -35,7 +37,7 @@ public function mount() $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); $this->projects = Project::ownedByCurrentTeam()->get(); - $this->servers = currentTeam()->servers; + $this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer()); } public function toggleVolumeCloning(bool $value) @@ -45,6 +47,8 @@ public function toggleVolumeCloning(bool $value) public function cloneTo($destination_id) { + $this->authorize('update', $this->resource); + $new_destination = StandaloneDocker::find($destination_id); if (! $new_destination) { $new_destination = SwarmDocker::find($destination_id); @@ -56,145 +60,7 @@ public function cloneTo($destination_id) $server = $new_destination->server; if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $name = 'clone-of-'.str($this->resource->name)->limit(20).'-'.$uuid; - $applicationSettings = $this->resource->settings; - $url = $this->resource->fqdn; - - if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateFqdn($server, $uuid); - } - - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - 'additional_servers_count', - 'additional_networks_count', - ])->fill([ - 'uuid' => $uuid, - 'name' => $name, - 'fqdn' => $url, - 'status' => 'exited', - 'destination_id' => $new_destination->id, - ]); - $new_resource->save(); - - if ($new_resource->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $customLabels = str(implode('|coolify|', generateLabelsApplication($new_resource)))->replace('|coolify|', "\n"); - $new_resource->custom_labels = base64_encode($customLabels); - $new_resource->save(); - } - - $new_resource->settings()->delete(); - if ($applicationSettings) { - $newApplicationSettings = $applicationSettings->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $new_resource->id, - ]); - $newApplicationSettings->save(); - } - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } - - $scheduledTasks = $this->resource->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'application_id' => $new_resource->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $applicationPreviews = $this->resource->previews()->get(); - foreach ($applicationPreviews as $preview) { - $newPreview = $preview->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $new_resource->id, - 'status' => 'exited', - ]); - $newPreview->save(); - } - - $persistentVolumes = $this->resource->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $this->resource->uuid)) { - $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); - } else { - $newName = $new_resource->uuid.'-'.str($volume->name)->afterLast('-'); - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $new_resource->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopApplication::dispatch($this->resource, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: (string) new Cuid2, - application: $this->resource, - server: $sourceServer, - destination: $this->resource->destination, - no_questions_asked: true - ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $this->resource->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $new_resource->id, - ]); - $newStorage->save(); - } - - $environmentVaribles = $this->resource->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } + $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); $route = route('project.application.configuration', [ 'project_uuid' => $this->projectUuid, @@ -412,7 +278,7 @@ public function cloneTo($destination_id) if ($this->cloneVolumeData) { try { - StopService::dispatch($application, false, false); + StopService::dispatch($application); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $application->service->destination->server; @@ -454,7 +320,7 @@ public function cloneTo($destination_id) if ($this->cloneVolumeData) { try { - StopService::dispatch($database->service, false, false); + StopService::dispatch($database->service); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $database->service->destination->server; @@ -485,6 +351,7 @@ public function cloneTo($destination_id) public function moveTo($environment_id) { try { + $this->authorize('update', $this->resource); $new_environment = Environment::findOrFail($environment_id); $this->resource->update([ 'environment_id' => $environment_id, diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index c286fee5a..e4b666532 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Shared\ScheduledTask; use App\Models\ScheduledTask; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; class Add extends Component { + use AuthorizesRequests; + public $parameters; #[Locked] @@ -20,6 +23,9 @@ class Add extends Component #[Locked] public Collection $containerNames; + #[Locked] + public $resource; + public string $name; public string $command; @@ -45,6 +51,22 @@ class Add extends Component public function mount() { $this->parameters = get_route_parameters(); + + // Get the resource based on type and id + switch ($this->type) { + case 'application': + $this->resource = \App\Models\Application::findOrFail($this->id); + break; + case 'service': + $this->resource = \App\Models\Service::findOrFail($this->id); + break; + case 'standalone-postgresql': + $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + break; + default: + throw new \Exception('Invalid resource type'); + } + if ($this->containerNames->count() > 0) { $this->container = $this->containerNames->first(); } @@ -53,6 +75,7 @@ public function mount() public function submit() { try { + $this->authorize('update', $this->resource); $this->validate(); $isValid = validate_cron_expression($this->frequency); if (! $isValid) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index 6f62a5b5b..ca2bbd9b4 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -105,6 +105,19 @@ public function loadMoreLogs() $this->currentPage++; } + public function loadAllLogs() + { + if (! $this->selectedExecution || ! $this->selectedExecution->message) { + return; + } + + $lines = collect(explode("\n", $this->selectedExecution->message)); + $totalLines = $lines->count(); + $totalPages = ceil($totalLines / $this->logsPerPage); + + $this->currentPage = $totalPages; + } + public function getLogLinesProperty() { if (! $this->selectedExecution) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index fe6e36d5c..c8d07ae36 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -6,12 +6,15 @@ use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Application|Service $resource; public ScheduledTask $task; @@ -109,6 +112,7 @@ public function syncData(bool $toModel = false) public function instantSave() { try { + $this->authorize('update', $this->resource); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); $this->refreshTasks(); @@ -120,6 +124,7 @@ public function instantSave() public function submit() { try { + $this->authorize('update', $this->resource); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); } catch (\Exception $e) { @@ -139,6 +144,7 @@ public function refreshTasks() public function delete() { try { + $this->authorize('update', $this->resource); $this->task->delete(); if ($this->type === 'application') { @@ -154,6 +160,7 @@ public function delete() public function executeNow() { try { + $this->authorize('update', $this->resource); ScheduledTaskJob::dispatch($this->task); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { diff --git a/app/Livewire/Project/Shared/Storages/Add.php b/app/Livewire/Project/Shared/Storages/Add.php deleted file mode 100644 index dc015386c..000000000 --- a/app/Livewire/Project/Shared/Storages/Add.php +++ /dev/null @@ -1,165 +0,0 @@ - 'required|string', - 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', - 'file_storage_path' => 'string', - 'file_storage_content' => 'nullable|string', - 'file_storage_directory_source' => 'string', - 'file_storage_directory_destination' => 'string', - ]; - - protected $listeners = ['clearAddStorage' => 'clear']; - - protected $validationAttributes = [ - 'name' => 'name', - 'mount_path' => 'mount', - 'host_path' => 'host', - 'file_storage_path' => 'file storage path', - 'file_storage_content' => 'file storage content', - 'file_storage_directory_source' => 'file storage directory source', - 'file_storage_directory_destination' => 'file storage directory destination', - ]; - - public function mount() - { - if (str($this->resource->getMorphClass())->contains('Standalone')) { - $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; - } else { - $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; - } - $this->uuid = $this->resource->uuid; - $this->parameters = get_route_parameters(); - if (data_get($this->parameters, 'application_uuid')) { - $applicationUuid = $this->parameters['application_uuid']; - $application = Application::where('uuid', $applicationUuid)->first(); - if (! $application) { - abort(404); - } - if ($application->destination->server->isSwarm()) { - $this->isSwarm = true; - $this->rules['host_path'] = 'required|string'; - } - } - } - - public function submitFileStorage() - { - try { - $this->validate([ - 'file_storage_path' => 'string', - 'file_storage_content' => 'nullable|string', - ]); - - $this->file_storage_path = trim($this->file_storage_path); - $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); - - if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { - $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } else { - throw new \Exception('No valid resource type for file mount storage type!'); - } - - LocalFileVolume::create( - [ - 'fs_path' => $fs_path, - 'mount_path' => $this->file_storage_path, - 'content' => $this->file_storage_content, - 'is_directory' => false, - 'resource_id' => $this->resource->id, - 'resource_type' => get_class($this->resource), - ], - ); - $this->dispatch('refreshStorages'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submitFileStorageDirectory() - { - try { - $this->validate([ - 'file_storage_directory_source' => 'string', - 'file_storage_directory_destination' => 'string', - ]); - - $this->file_storage_directory_source = trim($this->file_storage_directory_source); - $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value(); - $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); - $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); - - LocalFileVolume::create( - [ - 'fs_path' => $this->file_storage_directory_source, - 'mount_path' => $this->file_storage_directory_destination, - 'is_directory' => true, - 'resource_id' => $this->resource->id, - 'resource_type' => get_class($this->resource), - ], - ); - $this->dispatch('refreshStorages'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submitPersistentVolume() - { - try { - $this->validate([ - 'name' => 'required|string', - 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', - ]); - $name = $this->uuid.'-'.$this->name; - $this->dispatch('addNewVolume', [ - 'name' => $name, - 'mount_path' => $this->mount_path, - 'host_path' => $this->host_path, - ]); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function clear() - { - $this->name = ''; - $this->mount_path = ''; - $this->host_path = null; - } -} diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index c26315d3b..63fc06a36 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -9,4 +9,15 @@ class All extends Component public $resource; protected $listeners = ['refreshStorages' => '$refresh']; + + public function getFirstStorageIdProperty() + { + if ($this->resource->persistentStorages->isEmpty()) { + return null; + } + + // Use the storage with the smallest ID as the "first" one + // This ensures stability even when storages are deleted + return $this->resource->persistentStorages->sortBy('id')->first()->id; + } } diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 54b1be3af..5970ec904 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -4,14 +4,19 @@ use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public LocalPersistentVolume $storage; + public $resource; + public bool $isReadOnly = false; public bool $isFirst = true; @@ -20,27 +25,65 @@ class Show extends Component public ?string $startedAt = null; + // Explicit properties + public string $name; + + public string $mountPath; + + public ?string $hostPath = null; + protected $rules = [ - 'storage.name' => 'required|string', - 'storage.mount_path' => 'required|string', - 'storage.host_path' => 'string|nullable', + 'name' => 'required|string', + 'mountPath' => 'required|string', + 'hostPath' => 'string|nullable', ]; protected $validationAttributes = [ 'name' => 'name', - 'mount_path' => 'mount', - 'host_path' => 'host', + 'mountPath' => 'mount', + 'hostPath' => 'host', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->storage->name = $this->name; + $this->storage->mount_path = $this->mountPath; + $this->storage->host_path = $this->hostPath; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->storage->name; + $this->mountPath = $this->storage->mount_path; + $this->hostPath = $this->storage->host_path; + } + } + + public function mount() + { + $this->syncData(false); + $this->isReadOnly = $this->storage->isReadOnlyVolume(); + } + public function submit() { + $this->authorize('update', $this->resource); + $this->validate(); + $this->syncData(true); $this->storage->save(); $this->dispatch('success', 'Storage updated successfully'); } public function delete($password) { + $this->authorize('update', $this->resource); + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 811859cb8..37b8b277a 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Shared; use App\Models\Tag; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; // Refactored ✅ class Tags extends Component { + use AuthorizesRequests; + public $resource = null; #[Validate('required|string|min:2')] @@ -34,6 +37,7 @@ public function loadTags() public function submit() { try { + $this->authorize('update', $this->resource); $this->validate(); $tags = str($this->newTags)->trim()->explode(' '); foreach ($tags as $tag) { @@ -66,6 +70,7 @@ public function submit() public function addTag(string $id, string $name) { try { + $this->authorize('update', $this->resource); $name = strip_tags($name); if ($this->resource->tags()->where('id', $id)->exists()) { $this->dispatch('error', 'Duplicate tags.', "Tag $name already added."); @@ -83,6 +88,7 @@ public function addTag(string $id, string $name) public function deleteTag(string $id) { try { + $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id); if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index a3d1aa10f..de2deeed4 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -44,6 +44,9 @@ private function checkShellAvailability(Server $server, string $container): bool public function sendTerminalCommand($isContainer, $identifier, $serverUuid) { $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); + if (! $server->isTerminalEnabled() || $server->isForceDisabled()) { + abort(403, 'Terminal access is disabled on this server.'); + } if ($isContainer) { // Validate container identifier format (alphanumeric, dashes, and underscores only) @@ -65,11 +68,16 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid) // Escape the identifier for shell usage $escapedIdentifier = escapeshellarg($identifier); - $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c 'PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. + 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. + 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; + $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'"); } else { - $command = SshMultiplexingHelper::generateSshCommand($server, 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n "$SHELL" ]; then exec $SHELL; else sh; fi'); + $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. + 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. + 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; + $command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand); } - // ssh command is sent back to frontend then to websocket // this is done because the websocket connection is not available here // a better solution would be to remove websocket on NodeJS and work with something like diff --git a/app/Livewire/Project/Shared/Webhooks.php b/app/Livewire/Project/Shared/Webhooks.php index 57c65c4dd..eafc653d5 100644 --- a/app/Livewire/Project/Shared/Webhooks.php +++ b/app/Livewire/Project/Shared/Webhooks.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; // Refactored ✅ class Webhooks extends Component { + use AuthorizesRequests; + public $resource; public ?string $deploywebhook; diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index 886a20218..7e828d14c 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -4,7 +4,7 @@ use App\Models\Environment; use App\Models\Project; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -12,12 +12,23 @@ class Show extends Component { public Project $project; - #[Validate(['required', 'string', 'min:3'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function mount(string $project_uuid) { try { diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 72684bdc6..a263acedf 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -3,10 +3,14 @@ namespace App\Livewire\Security; use App\Models\InstanceSettings; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Laravel\Sanctum\PersonalAccessToken; use Livewire\Component; class ApiTokens extends Component { + use AuthorizesRequests; + public ?string $description = null; public $tokens = []; @@ -15,6 +19,10 @@ class ApiTokens extends Component public $isApiEnabled; + public bool $canUseRootPermissions = false; + + public bool $canUseWritePermissions = false; + public function render() { return view('livewire.security.api-tokens'); @@ -23,6 +31,8 @@ public function render() public function mount() { $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; + $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); + $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->getTokens(); } @@ -33,6 +43,23 @@ private function getTokens() public function updatedPermissions($permissionToUpdate) { + // Check if user is trying to use restricted permissions + if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { + $this->dispatch('error', 'You do not have permission to use root permissions.'); + // Remove root from permissions if it was somehow added + $this->permissions = array_diff($this->permissions, ['root']); + + return; + } + + if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { + $this->dispatch('error', 'You do not have permission to use write permissions.'); + // Remove write permissions if they were somehow added + $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); + + return; + } + if ($permissionToUpdate == 'root') { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { @@ -50,6 +77,17 @@ public function updatedPermissions($permissionToUpdate) public function addNewToken() { try { + $this->authorize('create', PersonalAccessToken::class); + + // Validate permissions based on user role + if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { + throw new \Exception('You do not have permission to create tokens with root permissions.'); + } + + if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) { + throw new \Exception('You do not have permission to create tokens with write permissions.'); + } + $this->validate([ 'description' => 'required|min:3|max:255', ]); @@ -65,6 +103,7 @@ public function revoke(int $id) { try { $token = auth()->user()->tokens()->where('id', $id)->firstOrFail(); + $this->authorize('delete', $token); $token->delete(); $this->getTokens(); } catch (\Exception $e) { diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php new file mode 100644 index 000000000..33beff334 --- /dev/null +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -0,0 +1,101 @@ +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); + } + } + + protected function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function save() + { + $this->validate(); + + try { + if ($this->scriptId) { + // Update existing script + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId); + $this->authorize('update', $cloudInitScript); + + $cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + $message = 'Cloud-init script updated successfully.'; + } else { + // Create new script + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->name, + 'script' => $this->script, + ]); + + $message = 'Cloud-init script created successfully.'; + } + + // Only reset fields if creating (not editing) + if (! $this->scriptId) { + $this->reset(['name', 'script']); + } + + $this->dispatch('scriptSaved'); + $this->dispatch('success', $message); + + if ($this->modal_mode) { + $this->dispatch('closeModal'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-init-script-form'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php new file mode 100644 index 000000000..13bcf2caa --- /dev/null +++ b/app/Livewire/Security/CloudInitScripts.php @@ -0,0 +1,52 @@ +authorize('viewAny', CloudInitScript::class); + $this->loadScripts(); + } + + public function getListeners() + { + return [ + 'scriptSaved' => 'loadScripts', + ]; + } + + public function loadScripts() + { + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); + } + + public function deleteScript(int $scriptId) + { + try { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('delete', $script); + + $script->delete(); + $this->loadScripts(); + + $this->dispatch('success', 'Cloud-init script deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-init-scripts'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php new file mode 100644 index 000000000..7affb1531 --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -0,0 +1,99 @@ +authorize('create', CloudProviderToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => 'required|string|in:hetzner,digitalocean', + 'token' => 'required|string', + 'name' => 'required|string|max:255', + ]; + } + + protected function messages(): array + { + return [ + 'provider.required' => 'Please select a cloud provider.', + 'provider.in' => 'Invalid cloud provider selected.', + 'token.required' => 'API token is required.', + 'name.required' => 'Token name is required.', + ]; + } + + private function validateToken(string $provider, string $token): bool + { + try { + if ($provider === 'hetzner') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ray($response); + + return $response->successful(); + } + + // Add other providers here in the future + // if ($provider === 'digitalocean') { ... } + + return false; + } catch (\Throwable $e) { + return false; + } + } + + public function addToken() + { + $this->validate(); + + try { + // Validate the token with the provider's API + if (! $this->validateToken($this->provider, $this->token)) { + return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); + } + + $savedToken = CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => $this->provider, + 'token' => $this->token, + 'name' => $this->name, + ]); + + $this->reset(['token', 'name']); + + // Dispatch event with token ID so parent components can react + $this->dispatch('tokenAdded', tokenId: $savedToken->id); + + $this->dispatch('success', 'Cloud provider token added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token-form'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php new file mode 100644 index 000000000..f05b3c0ca --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -0,0 +1,60 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'loadTokens', + ]; + } + + public function loadTokens() + { + $this->tokens = CloudProviderToken::ownedByCurrentTeam()->get(); + } + + public function deleteToken(int $tokenId) + { + try { + $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); + $this->authorize('delete', $token); + + // Check if any servers are using this token + if ($token->hasServers()) { + $serverCount = $token->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return; + } + + $token->delete(); + $this->loadTokens(); + + $this->dispatch('success', 'Cloud provider token deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-tokens'); + } +} diff --git a/app/Livewire/Security/CloudTokens.php b/app/Livewire/Security/CloudTokens.php new file mode 100644 index 000000000..d6d1333f1 --- /dev/null +++ b/app/Livewire/Security/CloudTokens.php @@ -0,0 +1,13 @@ + 'required|string', - 'value' => 'required|string', - ]; + public bool $modal_mode = false; + + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'value' => 'required|string', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'value.required' => 'The Private Key field is required.', + 'value.string' => 'The Private Key must be a valid string.', + ] + ); + } public function generateNewRSAKey() { @@ -50,6 +71,7 @@ public function createPrivateKey() $this->validate(); try { + $this->authorize('create', PrivateKey::class); $privateKey = PrivateKey::createAndStore([ 'name' => $this->name, 'description' => $this->description, @@ -57,6 +79,14 @@ public function createPrivateKey() 'team_id' => currentTeam()->id, ]); + // If in modal mode, dispatch event and don't redirect + if ($this->modal_mode) { + $this->dispatch('privateKeyCreated', keyId: $privateKey->id); + $this->dispatch('success', 'Private key created successfully.'); + + return; + } + return $this->redirectAfterCreation($privateKey); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 76441a67e..950ec152d 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -3,10 +3,13 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description'])->get(); @@ -18,6 +21,7 @@ public function render() public function cleanupUnusedKeys() { + $this->authorize('create', PrivateKey::class); PrivateKey::cleanupUnusedKeys(); $this->dispatch('success', 'Unused keys have been cleaned up.'); } diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index b9195b543..9928cfe97 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -3,31 +3,84 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public PrivateKey $private_key; + // Explicit properties + public string $name; + + public ?string $description = null; + + public string $privateKeyValue; + + public bool $isGitRelated = false; + public $public_key = 'Loading...'; - protected $rules = [ - 'private_key.name' => 'required|string', - 'private_key.description' => 'nullable|string', - 'private_key.private_key' => 'required|string', - 'private_key.is_git_related' => 'nullable|boolean', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'privateKeyValue' => 'required|string', + 'isGitRelated' => 'nullable|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'privateKeyValue.required' => 'The Private Key field is required.', + 'privateKeyValue.string' => 'The Private Key must be a valid string.', + ] + ); + } protected $validationAttributes = [ - 'private_key.name' => 'name', - 'private_key.description' => 'description', - 'private_key.private_key' => 'private key', + 'name' => 'name', + 'description' => 'description', + 'privateKeyValue' => 'private key', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->private_key->name = $this->name; + $this->private_key->description = $this->description; + $this->private_key->private_key = $this->privateKeyValue; + $this->private_key->is_git_related = $this->isGitRelated; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->private_key->name; + $this->description = $this->private_key->description; + $this->privateKeyValue = $this->private_key->private_key; + $this->isGitRelated = $this->private_key->is_git_related; + } + } + public function mount() { try { $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + $this->syncData(false); } catch (\Throwable) { abort(404); } @@ -44,6 +97,7 @@ public function loadPublicKey() public function delete() { try { + $this->authorize('delete', $this->private_key); $this->private_key->safeDelete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); @@ -58,6 +112,11 @@ public function delete() public function changePrivateKey() { try { + $this->authorize('update', $this->private_key); + + $this->validate(); + + $this->syncData(true); $this->private_key->updatePrivateKey([ 'private_key' => formatPrivateKey($this->private_key->private_key), ]); diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index b2b8b1518..8d17bb557 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -2,11 +2,7 @@ namespace App\Livewire\Server; -use App\Helpers\SslHelper; -use App\Jobs\RegenerateSslCertJob; use App\Models\Server; -use App\Models\SslCertificate; -use Carbon\Carbon; use Livewire\Attributes\Validate; use Livewire\Component; @@ -14,14 +10,6 @@ class Advanced extends Component { public Server $server; - public ?SslCertificate $caCertificate = null; - - public $showCertificate = false; - - public $certificateContent = ''; - - public ?Carbon $certificateValidUntil = null; - public array $parameters = []; #[Validate(['string'])] @@ -42,102 +30,16 @@ public function mount(string $server_uuid) $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->parameters = get_route_parameters(); $this->syncData(); - $this->loadCaCertificate(); + } catch (\Throwable) { return redirect()->route('server.index'); } } - public function loadCaCertificate() - { - $this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first(); - - if ($this->caCertificate) { - $this->certificateContent = $this->caCertificate->ssl_certificate; - $this->certificateValidUntil = $this->caCertificate->valid_until; - } - } - - public function toggleCertificate() - { - $this->showCertificate = ! $this->showCertificate; - } - - public function saveCaCertificate() - { - try { - if (! $this->certificateContent) { - throw new \Exception('Certificate content cannot be empty.'); - } - - if (! openssl_x509_read($this->certificateContent)) { - throw new \Exception('Invalid certificate format.'); - } - - if ($this->caCertificate) { - $this->caCertificate->ssl_certificate = $this->certificateContent; - $this->caCertificate->save(); - - $this->loadCaCertificate(); - - $this->writeCertificateToServer(); - - dispatch(new RegenerateSslCertJob( - server_id: $this->server->id, - force_regeneration: true - )); - } - $this->dispatch('success', 'CA Certificate saved successfully.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function regenerateCaCertificate() - { - try { - SslHelper::generateSslCertificate( - commonName: 'Coolify CA Certificate', - serverId: $this->server->id, - isCaCertificate: true, - validityDays: 10 * 365 - ); - - $this->loadCaCertificate(); - - $this->writeCertificateToServer(); - - dispatch(new RegenerateSslCertJob( - server_id: $this->server->id, - force_regeneration: true - )); - - $this->loadCaCertificate(); - $this->dispatch('success', 'CA Certificate regenerated successfully.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function writeCertificateToServer() - { - $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; - - $commands = collect([ - "mkdir -p $caCertPath", - "chown -R 9999:root $caCertPath", - "chmod -R 700 $caCertPath", - "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt", - "chmod 644 $caCertPath/coolify-ca.crt", - ]); - - remote_process($commands, $this->server); - } - public function syncData(bool $toModel = false) { if ($toModel) { + $this->authorize('update', $this->server); $this->validate(); $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php new file mode 100644 index 000000000..c929d9b3d --- /dev/null +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -0,0 +1,133 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->loadCaCertificate(); + } catch (\Throwable $e) { + return redirect()->route('server.index'); + } + + } + + public function loadCaCertificate() + { + $this->caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if ($this->caCertificate) { + $this->certificateContent = $this->caCertificate->ssl_certificate; + $this->certificateValidUntil = $this->caCertificate->valid_until; + } + } + + public function toggleCertificate() + { + $this->showCertificate = ! $this->showCertificate; + } + + public function saveCaCertificate() + { + try { + $this->authorize('manageCaCertificate', $this->server); + if (! $this->certificateContent) { + throw new \Exception('Certificate content cannot be empty.'); + } + + if (! openssl_x509_read($this->certificateContent)) { + throw new \Exception('Invalid certificate format.'); + } + + if ($this->caCertificate) { + $this->caCertificate->ssl_certificate = $this->certificateContent; + $this->caCertificate->save(); + + $this->loadCaCertificate(); + + $this->writeCertificateToServer(); + + dispatch(new RegenerateSslCertJob( + server_id: $this->server->id, + force_regeneration: true + )); + } + $this->dispatch('success', 'CA Certificate saved successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function regenerateCaCertificate() + { + try { + $this->authorize('manageCaCertificate', $this->server); + SslHelper::generateSslCertificate( + commonName: 'Coolify CA Certificate', + serverId: $this->server->id, + isCaCertificate: true, + validityDays: 10 * 365 + ); + + $this->loadCaCertificate(); + + $this->writeCertificateToServer(); + + dispatch(new RegenerateSslCertJob( + server_id: $this->server->id, + force_regeneration: true + )); + + $this->loadCaCertificate(); + $this->dispatch('success', 'CA Certificate regenerated successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function writeCertificateToServer() + { + $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + + $commands = collect([ + "mkdir -p $caCertPath", + "chown -R 9999:root $caCertPath", + "chmod -R 700 $caCertPath", + "rm -rf $caCertPath/coolify-ca.crt", + "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt", + "chmod 644 $caCertPath/coolify-ca.crt", + ]); + + remote_process($commands, $this->server); + } + + public function render() + { + return view('livewire.server.ca-certificate.show'); + } +} diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php new file mode 100644 index 000000000..6b22fddc6 --- /dev/null +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -0,0 +1,144 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + ]; + } + + public function loadTokens() + { + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + $this->loadTokens(); + } + + public function setCloudProviderToken($tokenId) + { + $ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId); + if (is_null($ownedToken)) { + $this->dispatch('error', 'You are not allowed to use this token.'); + + return; + } + try { + $this->authorize('update', $this->server); + + // Validate the token works and can access this specific server + $validationResult = $this->validateTokenForServer($ownedToken); + if (! $validationResult['valid']) { + $this->dispatch('error', $validationResult['error']); + + return; + } + + $this->server->cloudProviderToken()->associate($ownedToken); + $this->server->save(); + $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('refreshServerShow'); + } catch (\Exception $e) { + $this->server->refresh(); + $this->dispatch('error', $e->getMessage()); + } + } + + private function validateTokenForServer(CloudProviderToken $token): array + { + try { + // First, validate the token itself + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if (! $response->successful()) { + return [ + 'valid' => false, + 'error' => 'This token is invalid or has insufficient permissions.', + ]; + } + + // Check if this token can access the specific Hetzner server + if ($this->server->hetzner_server_id) { + $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this server. It may belong to a different Hetzner project.', + ]; + } + } + + return ['valid' => true]; + } catch (\Throwable $e) { + return [ + 'valid' => false, + 'error' => 'Failed to validate token: '.$e->getMessage(), + ]; + } + } + + public function validateToken() + { + try { + $token = $this->server->cloudProviderToken; + if (! $token) { + $this->dispatch('error', 'No Hetzner token is associated with this server.'); + + return; + } + + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if ($response->successful()) { + $this->dispatch('success', 'Hetzner token is valid and working.'); + } else { + $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php new file mode 100644 index 000000000..24f8e022e --- /dev/null +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -0,0 +1,102 @@ +user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'refresh', + ]; + } + + public function refresh() + { + $this->server->refresh(); + $this->isCloudflareTunnelsEnabled = $this->server->settings->is_cloudflare_tunnel; + } + + public function mount(string $server_uuid) + { + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + if ($this->server->isLocalhost()) { + return redirect()->route('server.show', ['server_uuid' => $server_uuid]); + } + $this->isCloudflareTunnelsEnabled = $this->server->settings->is_cloudflare_tunnel; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function toggleCloudflareTunnels() + { + try { + $this->authorize('update', $this->server); + remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + $this->isCloudflareTunnelsEnabled = false; + $this->server->settings->is_cloudflare_tunnel = false; + $this->server->settings->save(); + if ($this->server->ip_previous) { + $this->server->update(['ip' => $this->server->ip_previous]); + $this->dispatch('success', 'Cloudflare Tunnel disabled.

Manually updated the server IP address to its previous IP address.'); + } else { + $this->dispatch('warning', 'Cloudflare Tunnel disabled. Action required: Update the server IP address to its real IP address in the Advanced settings.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + 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.'); + } + + public function automatedCloudflareConfig() + { + try { + $this->authorize('update', $this->server); + if (str($this->ssh_domain)->contains('https://')) { + $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); + $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); + } + $activity = ConfigureCloudflared::run($this->server, $this->cloudflare_token, $this->ssh_domain); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.cloudflare-tunnel'); + } +} diff --git a/app/Livewire/Server/CloudflareTunnels.php b/app/Livewire/Server/CloudflareTunnels.php deleted file mode 100644 index f69fc8655..000000000 --- a/app/Livewire/Server/CloudflareTunnels.php +++ /dev/null @@ -1,54 +0,0 @@ -server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); - if ($this->server->isLocalhost()) { - return redirect()->route('server.show', ['server_uuid' => $server_uuid]); - } - $this->isCloudflareTunnelsEnabled = $this->server->settings->is_cloudflare_tunnel; - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSave() - { - try { - $this->validate(); - $this->server->settings->is_cloudflare_tunnel = $this->isCloudflareTunnelsEnabled; - $this->server->settings->save(); - $this->dispatch('success', 'Server updated.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function manualCloudflareConfig() - { - $this->isCloudflareTunnelsEnabled = true; - $this->server->settings->is_cloudflare_tunnel = true; - $this->server->settings->save(); - $this->server->refresh(); - $this->dispatch('success', 'Cloudflare Tunnels enabled.'); - } - - public function render() - { - return view('livewire.server.cloudflare-tunnels'); - } -} diff --git a/app/Livewire/Server/ConfigureCloudflareTunnels.php b/app/Livewire/Server/ConfigureCloudflareTunnels.php deleted file mode 100644 index f27614aa4..000000000 --- a/app/Livewire/Server/ConfigureCloudflareTunnels.php +++ /dev/null @@ -1,54 +0,0 @@ -where('id', $this->server_id)->firstOrFail(); - $server->settings->is_cloudflare_tunnel = true; - $server->settings->save(); - $this->dispatch('success', 'Cloudflare Tunnels configured successfully.'); - $this->dispatch('refreshServerShow'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submit() - { - try { - if (str($this->ssh_domain)->contains('https://')) { - $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); - // remove / from the end - $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); - } - $server = Server::ownedByCurrentTeam()->where('id', $this->server_id)->firstOrFail(); - ConfigureCloudflared::dispatch($server, $this->cloudflare_token); - $server->settings->is_cloudflare_tunnel = true; - $server->ip = $this->ssh_domain; - $server->save(); - $server->settings->save(); - $this->dispatch('info', 'Cloudflare Tunnels configuration started.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function render() - { - return view('livewire.server.configure-cloudflare-tunnels'); - } -} diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 2d4ba4430..cf77664fe 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Team; use Livewire\Component; @@ -12,6 +13,8 @@ class Create extends Component public bool $limit_reached = false; + public bool $has_hetzner_tokens = false; + public function mount() { $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); @@ -21,6 +24,11 @@ public function mount() return; } $this->limit_reached = Team::serverLimitReached(); + + // Check if user has Hetzner tokens + $this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->exists(); } public function render() diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index b9e3944b5..8c2c54c99 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,8 @@ class Delete extends Component public Server $server; + public bool $delete_from_hetzner = false; + public function mount(string $server_uuid) { try { @@ -41,8 +43,15 @@ public function delete($password) return; } + $this->server->delete(); - DeleteServer::dispatch($this->server); + DeleteServer::dispatch( + $this->server->id, + $this->delete_from_hetzner, + $this->server->hetzner_server_id, + $this->server->cloud_provider_token_id, + $this->server->team_id + ); return redirect()->route('server.index'); } catch (\Throwable $e) { @@ -52,6 +61,18 @@ public function delete($password) public function render() { - return view('livewire.server.delete'); + $checkboxes = []; + + if ($this->server->hetzner_server_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hetzner', + 'label' => 'Also delete server from Hetzner Cloud', + 'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.', + ]; + } + + return view('livewire.server.delete', [ + 'checkboxes' => $checkboxes, + ]); } } diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index dbab6e03f..3dbb3fcf8 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -5,11 +5,14 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class Destinations extends Component { + use AuthorizesRequests; + public Server $server; public Collection $networks; @@ -33,6 +36,7 @@ private function createNetworkAndAttachToProxy() public function add($name) { if ($this->server->isSwarm()) { + $this->authorize('create', SwarmDocker::class); $found = $this->server->swarmDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); @@ -46,6 +50,7 @@ public function add($name) ]); } } else { + $this->authorize('create', StandaloneDocker::class); $found = $this->server->standaloneDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index d3378d63f..764e583cd 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -4,11 +4,14 @@ use App\Jobs\DockerCleanupJob; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class DockerCleanup extends Component { + use AuthorizesRequests; + public Server $server; public array $parameters = []; @@ -42,6 +45,7 @@ public function mount(string $server_uuid) public function syncData(bool $toModel = false) { if ($toModel) { + $this->authorize('update', $this->server); $this->validate(); $this->server->settings->force_docker_cleanup = $this->forceDockerCleanup; $this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency; @@ -71,7 +75,8 @@ public function instantSave() public function manualCleanup() { try { - DockerCleanupJob::dispatch($this->server, true); + $this->authorize('update', $this->server); + DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index edddfc755..d4a65af81 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -5,11 +5,14 @@ use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class LogDrains extends Component { + use AuthorizesRequests; + public Server $server; #[Validate(['boolean'])] @@ -160,6 +163,7 @@ public function customValidation() public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); if ($this->server->isLogDrainEnabled()) { StartLogDrain::run($this->server); @@ -176,6 +180,7 @@ public function instantSave() public function submit(string $type) { try { + $this->authorize('update', $this->server); $this->syncData(true, $type); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php new file mode 100644 index 000000000..6baa54672 --- /dev/null +++ b/app/Livewire/Server/Navbar.php @@ -0,0 +1,161 @@ +user()->currentTeam()->id; + + return [ + 'refreshServerShow' => 'refreshServer', + "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', + ]; + } + + public function mount(Server $server) + { + $this->server = $server; + $this->currentRoute = request()->route()->getName(); + $this->serverIp = $this->server->id === 0 ? base_ip() : $this->server->ip; + $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + $this->loadProxyConfiguration(); + } + + public function loadProxyConfiguration() + { + try { + if ($this->proxyStatus === 'running') { + $this->traefikDashboardAvailable = ProxyDashboardCacheService::isTraefikDashboardAvailableFromCache($this->server); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function restart() + { + try { + $this->authorize('manageProxy', $this->server); + RestartProxyJob::dispatch($this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function checkProxy() + { + try { + $this->authorize('manageProxy', $this->server); + CheckProxy::run($this->server, true); + $this->dispatch('startProxy')->self(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function startProxy() + { + try { + $this->authorize('manageProxy', $this->server); + $activity = StartProxy::run($this->server, force: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function stop(bool $forceStop = true) + { + try { + $this->authorize('manageProxy', $this->server); + StopProxy::dispatch($this->server, $forceStop); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function checkProxyStatus() + { + if ($this->isChecking) { + return; + } + + try { + $this->isChecking = true; + CheckProxy::run($this->server, true); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->isChecking = false; + $this->showNotification(); + } + } + + public function showNotification() + { + $this->server->refresh(); + $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + + switch ($this->proxyStatus) { + case 'running': + $this->loadProxyConfiguration(); + $this->dispatch('success', 'Proxy is running.'); + break; + case 'restarting': + $this->dispatch('info', 'Initiating proxy restart.'); + break; + case 'exited': + $this->dispatch('info', 'Proxy has exited.'); + break; + case 'stopping': + $this->dispatch('info', 'Proxy is stopping.'); + break; + case 'starting': + $this->dispatch('info', 'Proxy is starting.'); + break; + case 'unknown': + $this->dispatch('info', 'Proxy status is unknown.'); + break; + default: + $this->dispatch('info', 'Proxy status updated.'); + break; + } + + } + + public function refreshServer() + { + $this->server->refresh(); + $this->server->load('settings'); + } + + public function render() + { + return view('livewire.server.navbar'); + } +} diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php new file mode 100644 index 000000000..ca5c588f8 --- /dev/null +++ b/app/Livewire/Server/New/ByHetzner.php @@ -0,0 +1,587 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } + + public function loadSavedCloudInitScripts() + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection() + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + } + + public function loadTokens() + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + // Refresh token list + $this->loadTokens(); + + // Auto-select the new token + $this->selected_token_id = $tokenId; + + // Automatically proceed to next step + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId) + { + // Refresh private keys list + $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); + + // Auto-select the new key + $this->private_key_id = $keyId; + + // Clear validation errors for private_key_id + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_location' => 'required|string', + 'selected_image' => 'required|integer', + 'selected_server_type' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedHetznerSshKeyIds' => 'nullable|array', + 'selectedHetznerSshKeyIds.*' => 'integer', + 'enable_ipv4' => 'required|boolean', + 'enable_ipv6' => 'required|boolean', + 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Hetzner token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId) + { + $this->selected_token_id = $tokenId; + } + + private function validateHetznerToken(string $token): bool + { + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + + private function getHetznerToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function nextStep() + { + // Validate step 1 - just need a token selected + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + $hetznerToken = $this->getHetznerToken(); + + if (! $hetznerToken) { + return $this->dispatch('error', 'Please select a valid Hetzner token.'); + } + + // Load Hetzner data + $this->loadHetznerData($hetznerToken); + + // Move to step 2 + $this->current_step = 2; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep() + { + $this->current_step = 1; + } + + private function loadHetznerData(string $token) + { + $this->loading_data = true; + + try { + $hetznerService = new HetznerService($token); + + $this->locations = $hetznerService->getLocations(); + $this->serverTypes = $hetznerService->getServerTypes(); + + // Get images and sort by name + $images = $hetznerService->getImages(); + + ray('Raw images from Hetzner API', [ + 'total_count' => count($images), + 'types' => collect($images)->pluck('type')->unique()->values(), + 'sample' => array_slice($images, 0, 3), + ]); + + $this->images = collect($images) + ->filter(function ($image) { + // Only system images + if (! isset($image['type']) || $image['type'] !== 'system') { + return false; + } + + // Filter out deprecated images + if (isset($image['deprecated']) && $image['deprecated'] === true) { + return false; + } + + return true; + }) + ->sortBy('name') + ->values() + ->toArray(); + + ray('Filtered images', [ + 'filtered_count' => count($this->images), + 'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(), + ]); + + // Load SSH keys from Hetzner + $this->hetznerSshKeys = $hetznerService->getSshKeys(); + + ray('Hetzner SSH Keys', [ + 'total_count' => count($this->hetznerSshKeys), + 'keys' => $this->hetznerSshKeys, + ]); + + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + throw $e; + } + } + + private function getCpuVendorInfo(array $serverType): ?string + { + $name = strtolower($serverType['name'] ?? ''); + + if (str_starts_with($name, 'ccx')) { + return 'AMD Milan EPYC™'; + } elseif (str_starts_with($name, 'cpx')) { + return 'AMD EPYC™'; + } elseif (str_starts_with($name, 'cx')) { + return 'Intel® Xeon®'; + } elseif (str_starts_with($name, 'cax')) { + return 'Ampere® Altra®'; + } + + return null; + } + + public function getAvailableServerTypesProperty() + { + ray('Getting available server types', [ + 'selected_location' => $this->selected_location, + 'total_server_types' => count($this->serverTypes), + ]); + + if (! $this->selected_location) { + return $this->serverTypes; + } + + $filtered = collect($this->serverTypes) + ->filter(function ($type) { + if (! isset($type['locations'])) { + return false; + } + + $locationNames = collect($type['locations'])->pluck('name')->toArray(); + + return in_array($this->selected_location, $locationNames); + }) + ->map(function ($serverType) { + $serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType); + + return $serverType; + }) + ->values() + ->toArray(); + + ray('Filtered server types', [ + 'selected_location' => $this->selected_location, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function getAvailableImagesProperty() + { + ray('Getting available images', [ + 'selected_server_type' => $this->selected_server_type, + 'total_images' => count($this->images), + 'images' => $this->images, + ]); + + if (! $this->selected_server_type) { + return $this->images; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + ray('Server type data', $serverType); + + if (! $serverType || ! isset($serverType['architecture'])) { + ray('No architecture in server type, returning all'); + + return $this->images; + } + + $architecture = $serverType['architecture']; + + $filtered = collect($this->images) + ->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture) + ->values() + ->toArray(); + + ray('Filtered images', [ + 'architecture' => $architecture, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function getSelectedServerPriceProperty(): ?string + { + if (! $this->selected_server_type) { + return null; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) { + return null; + } + + $price = $serverType['prices'][0]['price_monthly']['gross']; + + return '€'.number_format($price, 2); + } + + public function updatedSelectedLocation($value) + { + ray('Location selected', $value); + + // Reset server type and image when location changes + $this->selected_server_type = null; + $this->selected_image = null; + } + + public function updatedSelectedServerType($value) + { + ray('Server type selected', $value); + + // Reset image when server type changes + $this->selected_image = null; + } + + public function updatedSelectedImage($value) + { + ray('Image selected', $value); + } + + public function updatedSelectedCloudInitScriptId($value) + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + } + } + + public function clearCloudInitScript() + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + } + + private function createHetznerServer(string $token): array + { + $hetznerService = new HetznerService($token); + + // Get the private key and extract public key + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + + $publicKey = $privateKey->getPublicKey(); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + ray('Private Key Info', [ + 'private_key_id' => $this->private_key_id, + 'sha256_fingerprint' => $privateKey->fingerprint, + 'md5_fingerprint' => $md5Fingerprint, + ]); + + // Check if SSH key already exists on Hetzner by comparing MD5 fingerprints + $existingSshKeys = $hetznerService->getSshKeys(); + $existingKey = null; + + ray('Existing SSH Keys on Hetzner', $existingSshKeys); + + foreach ($existingSshKeys as $key) { + if ($key['fingerprint'] === $md5Fingerprint) { + $existingKey = $key; + break; + } + } + + // Upload SSH key if it doesn't exist + if ($existingKey) { + $sshKeyId = $existingKey['id']; + ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]); + } else { + $sshKeyName = $privateKey->name; + $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); + $sshKeyId = $uploadedKey['id']; + ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); + } + + // Normalize server name to lowercase for RFC 1123 compliance + $normalizedServerName = strtolower(trim($this->server_name)); + + // Prepare SSH keys array: Coolify key + user-selected Hetzner keys + $sshKeys = array_merge( + [$sshKeyId], // Coolify key (always included) + $this->selectedHetznerSshKeyIds // User-selected Hetzner keys + ); + + // Remove duplicates in case the Coolify key was also selected + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); // Re-index array + + // Prepare server creation parameters + $params = [ + 'name' => $normalizedServerName, + 'server_type' => $this->selected_server_type, + 'image' => $this->selected_image, + 'location' => $this->selected_location, + 'start_after_create' => true, + 'ssh_keys' => $sshKeys, + 'public_net' => [ + 'enable_ipv4' => $this->enable_ipv4, + 'enable_ipv6' => $this->enable_ipv6, + ], + ]; + + // Add cloud-init script if provided + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + ray('Server creation parameters', $params); + + // Create server on Hetzner + $hetznerServer = $hetznerService->createServer($params); + + ray('Hetzner server created', $hetznerServer); + + return $hetznerServer; + } + + public function submit() + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + // Save cloud-init script if requested + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $hetznerToken = $this->getHetznerToken(); + + // Create server on Hetzner + $hetznerServer = $this->createHetznerServer($hetznerToken); + + // Determine IP address to use (prefer IPv4, fallback to IPv6) + $ipAddress = null; + if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; + } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + } + + if (! $ipAddress) { + throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); + } + + // Create server in Coolify database + $server = Server::create([ + 'name' => $this->server_name, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'hetzner_server_id' => $hetznerServer['id'], + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return redirect()->route('server.show', $server->uuid); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-hetzner'); + } +} diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 5f60c5db5..116775a6f 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -5,56 +5,46 @@ use App\Enums\ProxyTypes; use App\Models\Server; use App\Models\Team; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; -use Livewire\Attributes\Validate; use Livewire\Component; class ByIp extends Component { + use AuthorizesRequests; + #[Locked] public $private_keys; #[Locked] public $limit_reached; - #[Validate('nullable|integer', as: 'Private Key')] public ?int $private_key_id = null; - #[Validate('nullable|string', as: 'Private Key Name')] public $new_private_key_name; - #[Validate('nullable|string', as: 'Private Key Description')] public $new_private_key_description; - #[Validate('nullable|string', as: 'Private Key Value')] public $new_private_key_value; - #[Validate('required|string', as: 'Name')] public string $name; - #[Validate('nullable|string', as: 'Description')] public ?string $description = null; - #[Validate('required|string', as: 'IP Address/Domain')] public string $ip; - #[Validate('required|string', as: 'User')] public string $user = 'root'; - #[Validate('required|integer|between:1,65535', as: 'Port')] public int $port = 22; - #[Validate('required|boolean', as: 'Swarm Manager')] public bool $is_swarm_manager = false; - #[Validate('required|boolean', as: 'Swarm Worker')] public bool $is_swarm_worker = false; - #[Validate('nullable|integer', as: 'Swarm Cluster')] public $selected_swarm_cluster = null; - #[Validate('required|boolean', as: 'Build Server')] public bool $is_build_server = false; #[Locked] @@ -70,6 +60,50 @@ public function mount() } } + protected function rules(): array + { + return [ + 'private_key_id' => 'nullable|integer', + 'new_private_key_name' => 'nullable|string', + 'new_private_key_description' => 'nullable|string', + 'new_private_key_value' => 'nullable|string', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'ip' => 'required|string', + 'user' => 'required|string', + 'port' => 'required|integer|between:1,65535', + 'is_swarm_manager' => 'required|boolean', + 'is_swarm_worker' => 'required|boolean', + 'selected_swarm_cluster' => 'nullable|integer', + 'is_build_server' => 'required|boolean', + ]; + } + + protected function messages(): array + { + return array_merge(ValidationPatterns::combinedMessages(), [ + 'private_key_id.integer' => 'The Private Key field must be an integer.', + 'private_key_id.nullable' => 'The Private Key field is optional.', + 'new_private_key_name.string' => 'The Private Key Name must be a string.', + 'new_private_key_description.string' => 'The Private Key Description must be a string.', + 'new_private_key_value.string' => 'The Private Key Value must be a string.', + 'ip.required' => 'The IP Address/Domain is required.', + 'ip.string' => 'The IP Address/Domain must be a string.', + 'user.required' => 'The User field is required.', + 'user.string' => 'The User field must be a string.', + 'port.required' => 'The Port field is required.', + 'port.integer' => 'The Port field must be an integer.', + 'port.between' => 'The Port field must be between 1 and 65535.', + 'is_swarm_manager.required' => 'The Swarm Manager field is required.', + 'is_swarm_manager.boolean' => 'The Swarm Manager field must be true or false.', + 'is_swarm_worker.required' => 'The Swarm Worker field is required.', + 'is_swarm_worker.boolean' => 'The Swarm Worker field must be true or false.', + 'selected_swarm_cluster.integer' => 'The Swarm Cluster field must be an integer.', + 'is_build_server.required' => 'The Build Server field is required.', + 'is_build_server.boolean' => 'The Build Server field must be true or false.', + ]); + } + public function setPrivateKey(string $private_key_id) { $this->private_key_id = $private_key_id; @@ -84,6 +118,7 @@ public function submit() { $this->validate(); try { + $this->authorize('create', Server::class); if (Server::where('team_id', currentTeam()->id) ->where('ip', $this->ip) ->exists()) { diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 64aa1884b..fd55717fa 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -4,10 +4,14 @@ use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Server $server; public $privateKeys = []; @@ -32,18 +36,20 @@ public function setPrivateKey($privateKeyId) return; } - - $originalPrivateKeyId = $this->server->getOriginal('private_key_id'); try { - $this->server->update(['private_key_id' => $privateKeyId]); - ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true); - if ($uptime) { - $this->dispatch('success', 'Private key updated successfully.'); - } else { - throw new \Exception($error); - } + $this->authorize('update', $this->server); + DB::transaction(function () use ($ownedPrivateKey) { + $this->server->privateKey()->associate($ownedPrivateKey); + $this->server->save(); + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true); + if (! $uptime) { + throw new \Exception($error); + } + }); + $this->dispatch('success', 'Private key updated successfully.'); + $this->dispatch('refreshServerShow'); } catch (\Exception $e) { - $this->server->update(['private_key_id' => $originalPrivateKeyId]); + $this->server->refresh(); $this->server->validateConnection(); $this->dispatch('error', $e->getMessage()); } @@ -55,6 +61,7 @@ public function checkConnection() ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if ($uptime) { $this->dispatch('success', 'Server is reachable.'); + $this->dispatch('refreshServerShow'); } else { $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 4e325c1ff..bc7e9bde4 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,53 +2,80 @@ namespace App\Livewire\Server; -use App\Actions\Proxy\CheckConfiguration; -use App\Actions\Proxy\SaveConfiguration; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Proxy extends Component { + use AuthorizesRequests; + public Server $server; public ?string $selectedProxy = null; - public $proxy_settings = null; + public $proxySettings = null; - public bool $redirect_enabled = true; + public bool $redirectEnabled = true; - public ?string $redirect_url = null; + public ?string $redirectUrl = null; - protected $listeners = ['proxyStatusUpdated', 'saveConfiguration' => 'submit']; + public bool $generateExactLabels = false; + + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + 'saveConfiguration' => 'submit', + "echo-private:team.{$teamId},ProxyStatusChangedUI" => '$refresh', + ]; + } protected $rules = [ - 'server.settings.generate_exact_labels' => 'required|boolean', + 'generateExactLabels' => 'required|boolean', ]; public function mount() { $this->selectedProxy = $this->server->proxyType(); - $this->redirect_enabled = data_get($this->server, 'proxy.redirect_enabled', true); - $this->redirect_url = data_get($this->server, 'proxy.redirect_url'); + $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); + $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); + $this->syncData(false); } - public function proxyStatusUpdated() + private function syncData(bool $toModel = false): void { - $this->dispatch('refresh')->self(); + if ($toModel) { + $this->server->settings->generate_exact_labels = $this->generateExactLabels; + } else { + $this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false; + } + } + + public function getConfigurationFilePathProperty() + { + return $this->server->proxyPath().'docker-compose.yml'; } public function changeProxy() { + $this->authorize('update', $this->server); $this->server->proxy = null; $this->server->save(); + $this->dispatch('reloadWindow'); } public function selectProxy($proxy_type) { try { + $this->authorize('update', $this->server); $this->server->changeProxy($proxy_type, async: false); $this->selectedProxy = $this->server->proxy->type; + $this->dispatch('reloadWindow'); } catch (\Throwable $e) { return handleError($e, $this); @@ -58,7 +85,9 @@ public function selectProxy($proxy_type) public function instantSave() { try { + $this->authorize('update', $this->server); $this->validate(); + $this->syncData(true); $this->server->settings->save(); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { @@ -69,7 +98,8 @@ public function instantSave() public function instantSaveRedirect() { try { - $this->server->proxy->redirect_enabled = $this->redirect_enabled; + $this->authorize('update', $this->server); + $this->server->proxy->redirect_enabled = $this->redirectEnabled; $this->server->save(); $this->server->setupDefaultRedirect(); $this->dispatch('success', 'Proxy configuration saved.'); @@ -81,8 +111,9 @@ public function instantSaveRedirect() public function submit() { try { - SaveConfiguration::run($this->server, $this->proxy_settings); - $this->server->proxy->redirect_url = $this->redirect_url; + $this->authorize('update', $this->server); + SaveProxyConfiguration::run($this->server, $this->proxySettings); + $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); $this->server->setupDefaultRedirect(); $this->dispatch('success', 'Proxy configuration saved.'); @@ -91,13 +122,15 @@ public function submit() } } - public function reset_proxy_configuration() + public function resetProxyConfiguration() { try { - $this->proxy_settings = CheckConfiguration::run($this->server, true); - SaveConfiguration::run($this->server, $this->proxy_settings); + $this->authorize('update', $this->server); + // Explicitly regenerate default configuration + $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); + SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->save(); - $this->dispatch('success', 'Proxy configuration saved.'); + $this->dispatch('success', 'Proxy configuration reset to default.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -106,12 +139,7 @@ public function reset_proxy_configuration() public function loadProxyConfiguration() { try { - $this->proxy_settings = CheckConfiguration::run($this->server); - if (str($this->proxy_settings)->contains('--api.dashboard=true') && str($this->proxy_settings)->contains('--api.insecure=true')) { - $this->dispatch('traefikDashboardAvailable', true); - } else { - $this->dispatch('traefikDashboardAvailable', false); - } + $this->proxySettings = GetProxyConfiguration::run($this->server); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Proxy/Deploy.php b/app/Livewire/Server/Proxy/Deploy.php deleted file mode 100644 index 48eede4e5..000000000 --- a/app/Livewire/Server/Proxy/Deploy.php +++ /dev/null @@ -1,106 +0,0 @@ -user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ProxyStatusChanged" => 'proxyStarted', - 'proxyStatusUpdated', - 'traefikDashboardAvailable', - 'serverRefresh' => 'proxyStatusUpdated', - 'checkProxy', - 'startProxy', - 'proxyChanged' => 'proxyStatusUpdated', - ]; - } - - public function mount() - { - if ($this->server->id === 0) { - $this->serverIp = base_ip(); - } else { - $this->serverIp = $this->server->ip; - } - $this->currentRoute = request()->route()->getName(); - } - - public function traefikDashboardAvailable(bool $data) - { - $this->traefikDashboardAvailable = $data; - } - - public function proxyStarted() - { - CheckProxy::run($this->server, true); - $this->dispatch('proxyStatusUpdated'); - } - - public function proxyStatusUpdated() - { - $this->server->refresh(); - } - - public function restart() - { - try { - RestartProxyJob::dispatch($this->server); - $this->dispatch('checkProxy'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function checkProxy() - { - try { - CheckProxy::run($this->server, true); - $this->dispatch('startProxyPolling'); - $this->dispatch('proxyChecked'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function startProxy() - { - try { - $this->server->proxy->force_stop = false; - $this->server->save(); - $activity = StartProxy::run($this->server, force: true); - $this->dispatch('activityMonitor', $activity->id, ProxyStatusChanged::class); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function stop(bool $forceStop = true) - { - try { - StopProxy::run($this->server, $forceStop); - $this->dispatch('proxyStatusUpdated'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } -} diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index 392ad38fa..f377bbeb9 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -3,12 +3,17 @@ namespace App\Livewire\Server\Proxy; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class DynamicConfigurationNavbar extends Component { + use AuthorizesRequests; + public $server_id; + public Server $server; + public $fileName = ''; public $value = ''; @@ -17,18 +22,18 @@ class DynamicConfigurationNavbar extends Component public function delete(string $fileName) { - $server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first(); - $proxy_path = $server->proxyPath(); - $proxy_type = $server->proxyType(); + $this->authorize('update', $this->server); + $proxy_path = $this->server->proxyPath(); + $proxy_type = $this->server->proxyType(); $file = str_replace('|', '.', $fileName); if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { $this->dispatch('error', 'Cannot delete Caddyfile.'); return; } - instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $server); + instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server); if ($proxy_type === 'CADDY') { - $server->reloadCaddy(); + $this->server->reloadCaddy(); } $this->dispatch('success', 'File deleted.'); $this->dispatch('loadDynamicConfigurations'); diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index 7db890638..6ea9e7c3d 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -19,7 +19,7 @@ public function getListeners() $teamId = auth()->user()->currentTeam()->id; return [ - "echo-private:team.{$teamId},ProxyStatusChanged" => 'loadDynamicConfigurations', + "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'loadDynamicConfigurations', 'loadDynamicConfigurations', ]; } @@ -28,6 +28,11 @@ public function getListeners() 'contents.*' => 'nullable|string', ]; + public function initLoadDynamicConfigurations() + { + $this->loadDynamicConfigurations(); + } + public function loadDynamicConfigurations() { $proxy_path = $this->server->proxyPath(); @@ -43,6 +48,7 @@ public function loadDynamicConfigurations() } $this->contents = $contents; $this->dispatch('$refresh'); + $this->dispatch('success', 'Dynamic configurations loaded.'); } public function mount() diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index 2155f1e82..eb2db1cbb 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -4,11 +4,14 @@ use App\Enums\ProxyTypes; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Symfony\Component\Yaml\Yaml; class NewDynamicConfiguration extends Component { + use AuthorizesRequests; + public string $fileName = ''; public string $value = ''; @@ -23,6 +26,7 @@ class NewDynamicConfiguration extends Component public function mount() { + $this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first(); $this->parameters = get_route_parameters(); if ($this->fileName !== '') { $this->fileName = str_replace('|', '.', $this->fileName); @@ -32,6 +36,7 @@ public function mount() public function addDynamicConfiguration() { try { + $this->authorize('update', $this->server); $this->validate([ 'fileName' => 'required', 'value' => 'required', @@ -39,9 +44,7 @@ public function addDynamicConfiguration() if (data_get($this->parameters, 'server_uuid')) { $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); } - if (! is_null($this->server_id)) { - $this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first(); - } + if (is_null($this->server)) { return redirect()->route('server.index'); } diff --git a/app/Livewire/Server/Proxy/Show.php b/app/Livewire/Server/Proxy/Show.php index 5ecb56a69..fea7a98bb 100644 --- a/app/Livewire/Server/Proxy/Show.php +++ b/app/Livewire/Server/Proxy/Show.php @@ -11,13 +11,6 @@ class Show extends Component public $parameters = []; - protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated']; - - public function proxyStatusUpdated() - { - $this->server->refresh(); - } - public function mount() { $this->parameters = get_route_parameters(); diff --git a/app/Livewire/Server/Proxy/Status.php b/app/Livewire/Server/Proxy/Status.php deleted file mode 100644 index f4f18381f..000000000 --- a/app/Livewire/Server/Proxy/Status.php +++ /dev/null @@ -1,77 +0,0 @@ -checkProxy(); - } - - public function proxyStatusUpdated() - { - $this->server->refresh(); - } - - public function checkProxy(bool $notification = false) - { - try { - if ($this->polling) { - if ($this->numberOfPolls >= 10) { - $this->polling = false; - $this->numberOfPolls = 0; - $notification && $this->dispatch('error', 'Proxy is not running.'); - - return; - } - $this->numberOfPolls++; - } - $shouldStart = CheckProxy::run($this->server, true); - if ($shouldStart) { - StartProxy::run($this->server, false); - } - $this->dispatch('proxyStatusUpdated'); - if ($this->server->proxy->status === 'running') { - $this->polling = false; - $notification && $this->dispatch('success', 'Proxy is running.'); - } elseif ($this->server->proxy->status === 'exited' and ! $this->server->proxy->force_stop) { - $notification && $this->dispatch('error', 'Proxy has exited.'); - } elseif ($this->server->proxy->force_stop) { - $notification && $this->dispatch('error', 'Proxy is stopped manually.'); - } else { - $notification && $this->dispatch('error', 'Proxy is not running.'); - } - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function getProxyStatus() - { - try { - GetContainersStatus::run($this->server); - // dispatch_sync(new ContainerStatusJob($this->server)); - $this->dispatch('proxyStatusUpdated'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } -} diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 9392fc351..b4d151424 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -6,10 +6,14 @@ use App\Actions\Server\UpdatePackage; use App\Events\ServerPackageUpdated; use App\Models\Server; +use App\Notifications\Server\ServerPatchCheck; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Patches extends Component { + use AuthorizesRequests; + public array $parameters; public Server $server; @@ -35,11 +39,9 @@ public function getListeners() public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } $this->parameters = get_route_parameters(); $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); + $this->authorize('viewSecurity', $this->server); } public function checkForUpdatesDispatch() @@ -67,8 +69,10 @@ public function checkForUpdates() public function updateAllPackages() { + $this->authorize('update', $this->server); if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run “Check for updates” first.'); + $this->dispatch('error', message: 'Run "Check for updates" first.'); + return; } @@ -88,6 +92,7 @@ public function updateAllPackages() public function updatePackage($package) { try { + $this->authorize('update', $this->server); $activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, package: $package); $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); } catch (\Exception $e) { @@ -95,6 +100,89 @@ public function updatePackage($package) } } + public function sendTestEmail() + { + if (! isDev()) { + $this->dispatch('error', message: 'Test email functionality is only available in development mode.'); + + return; + } + + try { + // Get current patch data or create test data if none exists + $testPatchData = $this->createTestPatchData(); + + // Send test notification + $this->server->team->notify(new ServerPatchCheck($this->server, $testPatchData)); + + $this->dispatch('success', 'Test email sent successfully! Check your email inbox.'); + } catch (\Exception $e) { + $this->dispatch('error', message: 'Failed to send test email: '.$e->getMessage()); + } + } + + private function createTestPatchData(): array + { + // If we have real patch data, use it + if (isset($this->updates) && is_array($this->updates) && count($this->updates) > 0) { + return [ + 'total_updates' => $this->totalUpdates, + 'updates' => $this->updates, + 'osId' => $this->osId, + 'package_manager' => $this->packageManager, + ]; + } + + // Otherwise create realistic test data + return [ + 'total_updates' => 8, + 'updates' => [ + [ + 'package' => 'docker-ce', + 'current_version' => '24.0.7-1', + 'new_version' => '25.0.1-1', + ], + [ + 'package' => 'nginx', + 'current_version' => '1.20.2-1', + 'new_version' => '1.22.1-1', + ], + [ + 'package' => 'kernel-generic', + 'current_version' => '5.15.0-89', + 'new_version' => '5.15.0-91', + ], + [ + 'package' => 'openssh-server', + 'current_version' => '8.9p1-3', + 'new_version' => '9.0p1-1', + ], + [ + 'package' => 'curl', + 'current_version' => '7.81.0-1', + 'new_version' => '7.85.0-1', + ], + [ + 'package' => 'git', + 'current_version' => '2.34.1-1', + 'new_version' => '2.39.1-1', + ], + [ + 'package' => 'python3', + 'current_version' => '3.10.6-1', + 'new_version' => '3.11.0-1', + ], + [ + 'package' => 'htop', + 'current_version' => '3.2.1-1', + 'new_version' => '3.2.2-1', + ], + ], + 'osId' => $this->osId ?? 'ubuntu', + 'package_manager' => $this->packageManager ?? 'apt', + ]; + } + public function render() { return view('livewire.server.security.patches'); diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php new file mode 100644 index 000000000..284eea7dd --- /dev/null +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -0,0 +1,85 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('update', $this->server); + $this->parameters = get_route_parameters(); + $this->syncData(); + + } catch (\Throwable) { + return redirect()->route('server.index'); + } + } + + public function toggleTerminal($password) + { + try { + $this->authorize('update', $this->server); + + // Check if user is admin or owner + if (! auth()->user()->isAdmin()) { + throw new \Exception('Only team administrators and owners can modify terminal access.'); + } + + // Verify password unless two-step confirmation is disabled + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + + // Toggle the terminal setting + $this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled; + $this->server->settings->save(); + + // Update the local property + $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; + + $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; + $this->dispatch('success', "Terminal access has been {$status}."); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->authorize('update', $this->server); + $this->validate(); + // No other fields to sync for terminal access + } else { + $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; + } + } + + public function render() + { + return view('livewire.server.security.terminal-access'); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index b0e6d8858..4626a9135 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -6,94 +6,138 @@ use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; use App\Models\Server; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; -use Livewire\Attributes\Validate; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Server $server; - #[Validate(['required'])] public string $name; - #[Validate(['nullable'])] public ?string $description = null; - #[Validate(['required'])] public string $ip; - #[Validate(['required'])] public string $user; - #[Validate(['required'])] public string $port; - #[Validate(['nullable'])] public ?string $validationLogs = null; - #[Validate(['nullable', 'url'])] public ?string $wildcardDomain = null; - #[Validate(['required'])] public bool $isReachable; - #[Validate(['required'])] public bool $isUsable; - #[Validate(['required'])] public bool $isSwarmManager; - #[Validate(['required'])] public bool $isSwarmWorker; - #[Validate(['required'])] public bool $isBuildServer; #[Locked] public bool $isBuildServerLocked = false; - #[Validate(['required'])] public bool $isMetricsEnabled; - #[Validate(['required'])] public string $sentinelToken; - #[Validate(['nullable'])] public ?string $sentinelUpdatedAt = null; - #[Validate(['required', 'integer', 'min:1'])] public int $sentinelMetricsRefreshRateSeconds; - #[Validate(['required', 'integer', 'min:1'])] public int $sentinelMetricsHistoryDays; - #[Validate(['required', 'integer', 'min:10'])] public int $sentinelPushIntervalSeconds; - #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; - #[Validate(['required'])] public bool $isSentinelEnabled; - #[Validate(['required'])] public bool $isSentinelDebugEnabled; - #[Validate(['required'])] + public ?string $sentinelCustomDockerImage = null; + public string $serverTimezone; + public ?string $hetznerServerStatus = null; + + public bool $hetznerServerManuallyStarted = false; + + public bool $isValidating = false; + public function getListeners() { - $teamId = auth()->user()->currentTeam()->id; + $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; return [ - "echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'refresh', 'refreshServerShow' => 'refresh', + 'refreshServer' => '$refresh', + "echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted', + "echo-private:team.{$teamId},ServerValidated" => 'handleServerValidated', ]; } + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'ip' => 'required', + 'user' => 'required', + 'port' => 'required', + 'validationLogs' => 'nullable', + 'wildcardDomain' => 'nullable|url', + 'isReachable' => 'required', + 'isUsable' => 'required', + 'isSwarmManager' => 'required', + 'isSwarmWorker' => 'required', + 'isBuildServer' => 'required', + 'isMetricsEnabled' => 'required', + 'sentinelToken' => 'required', + 'sentinelUpdatedAt' => 'nullable', + 'sentinelMetricsRefreshRateSeconds' => 'required|integer|min:1', + 'sentinelMetricsHistoryDays' => 'required|integer|min:1', + 'sentinelPushIntervalSeconds' => 'required|integer|min:10', + 'sentinelCustomUrl' => 'nullable|url', + 'isSentinelEnabled' => 'required', + 'isSentinelDebugEnabled' => 'required', + 'serverTimezone' => 'required', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'ip.required' => 'The IP Address field is required.', + 'user.required' => 'The User field is required.', + 'port.required' => 'The Port field is required.', + 'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.', + 'sentinelToken.required' => 'The Sentinel Token field is required.', + 'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.', + 'sentinelMetricsRefreshRateSeconds.integer' => 'The Metrics Refresh Rate must be an integer.', + 'sentinelMetricsRefreshRateSeconds.min' => 'The Metrics Refresh Rate must be at least 1 second.', + 'sentinelMetricsHistoryDays.required' => 'The Metrics History Days field is required.', + 'sentinelMetricsHistoryDays.integer' => 'The Metrics History Days must be an integer.', + 'sentinelMetricsHistoryDays.min' => 'The Metrics History Days must be at least 1 day.', + 'sentinelPushIntervalSeconds.required' => 'The Push Interval field is required.', + 'sentinelPushIntervalSeconds.integer' => 'The Push Interval must be an integer.', + 'sentinelPushIntervalSeconds.min' => 'The Push Interval must be at least 10 seconds.', + 'sentinelCustomUrl.url' => 'The Custom Sentinel URL must be a valid URL.', + 'serverTimezone.required' => 'The Server Timezone field is required.', + ] + ); + } + public function mount(string $server_uuid) { try { @@ -102,6 +146,10 @@ public function mount(string $server_uuid) if (! $this->server->isEmpty()) { $this->isBuildServerLocked = true; } + // Load saved Hetzner status and validation state + $this->hetznerServerStatus = $this->server->hetzner_server_status; + $this->isValidating = $this->server->is_validating ?? false; + } catch (\Throwable $e) { return handleError($e, $this); } @@ -121,6 +169,7 @@ public function syncData(bool $toModel = false) if ($toModel) { $this->validate(); + $this->authorize('update', $this->server); if (Server::where('team_id', currentTeam()->id) ->where('ip', $this->ip) ->where('id', '!=', $this->server->id) @@ -179,20 +228,31 @@ public function syncData(bool $toModel = false) $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; - $this->sentinelUpdatedAt = $this->server->settings->updated_at; + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->serverTimezone = $this->server->settings->server_timezone; + $this->isValidating = $this->server->is_validating ?? false; } } public function refresh() { $this->syncData(); - $this->dispatch('$refresh'); + } + + public function handleSentinelRestarted($event) + { + // Only refresh if the event is for this server + if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { + $this->server->refresh(); + $this->syncData(); + $this->dispatch('success', 'Sentinel has been restarted successfully.'); + } } public function validateServer($install = true) { try { + $this->authorize('update', $this->server); $this->validationLogs = $this->server->validation_logs = null; $this->server->save(); $this->dispatch('init', $install); @@ -211,7 +271,6 @@ public function checkLocalhostConnection() $this->server->settings->is_usable = $this->isUsable = true; $this->server->settings->save(); ServerReachabilityChanged::dispatch($this->server); - $this->dispatch('proxyStatusUpdated'); } else { $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); @@ -221,40 +280,86 @@ public function checkLocalhostConnection() public function restartSentinel() { - $this->server->restartSentinel(); - $this->dispatch('success', 'Sentinel restarted.'); + try { + $this->authorize('manageSentinel', $this->server); + $customImage = isDev() ? $this->sentinelCustomDockerImage : null; + $this->server->restartSentinel($customImage); + $this->dispatch('info', 'Restarting Sentinel.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } public function updatedIsSentinelDebugEnabled($value) { - $this->submit(); - $this->restartSentinel(); + try { + $this->submit(); + $this->restartSentinel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function updatedIsMetricsEnabled($value) { - $this->submit(); - $this->restartSentinel(); + try { + $this->submit(); + $this->restartSentinel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function updatedIsBuildServer($value) + { + try { + $this->authorize('update', $this->server); + if ($value === true && $this->isSentinelEnabled) { + $this->isSentinelEnabled = false; + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + StopSentinel::dispatch($this->server); + $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + } + $this->submit(); + // Dispatch event to refresh the navbar + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function updatedIsSentinelEnabled($value) { - if ($value === true) { - StartSentinel::run($this->server, true); - } else { - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); + try { + $this->authorize('manageSentinel', $this->server); + if ($value === true) { + if ($this->isBuildServer) { + $this->isSentinelEnabled = false; + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + return; + } + $customImage = isDev() ? $this->sentinelCustomDockerImage : null; + StartSentinel::run($this->server, true, null, $customImage); + } else { + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + StopSentinel::dispatch($this->server); + } + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function regenerateSentinelToken() { try { + $this->authorize('manageSentinel', $this->server); $this->server->settings->generateSentinelToken(); - $this->dispatch('success', 'Token regenerated & Sentinel restarted.'); + $this->dispatch('success', 'Token regenerated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -262,14 +367,99 @@ public function regenerateSentinelToken() public function instantSave() { - $this->submit(); + try { + $this->syncData(true); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function checkHetznerServerStatus(bool $manual = false) + { + try { + if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); + + return; + } + + $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $serverData = $hetznerService->getServer($this->server->hetzner_server_id); + + $this->hetznerServerStatus = $serverData['status'] ?? null; + + // Save status to database without triggering model events + if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) { + $this->server->hetzner_server_status = $this->hetznerServerStatus; + $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); + } + if ($manual) { + $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown')); + } + + // If Hetzner server is off but Coolify thinks it's still reachable, update Coolify's state + if ($this->hetznerServerStatus === 'off' && $this->server->settings->is_reachable) { + ['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; + } + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function handleServerValidated($event = null) + { + // Check if event is for this server + if ($event && isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) { + return; + } + + // Refresh server data + $this->server->refresh(); + $this->syncData(); + + // Update validation state + $this->isValidating = $this->server->is_validating ?? false; + $this->dispatch('refreshServerShow'); + $this->dispatch('refreshServer'); + } + + public function startHetznerServer() + { + try { + if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); + + return; + } + + $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token); + $hetznerService->powerOnServer($this->server->hetzner_server_id); + + $this->hetznerServerStatus = 'starting'; + $this->server->update(['hetzner_server_status' => 'starting']); + $this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running + $this->dispatch('success', 'Hetzner server is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function submit() { try { $this->syncData(true); - $this->dispatch('success', 'Server updated.'); + $this->dispatch('success', 'Server settings updated.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 791ef9350..bbd7f3dd9 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -4,11 +4,15 @@ use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; +use App\Events\ServerValidated; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class ValidateAndInstall extends Component { + use AuthorizesRequests; + public Server $server; public int $number_of_tries = 0; @@ -27,8 +31,6 @@ class ValidateAndInstall extends Component public $docker_version = null; - public $proxy_started = false; - public $error = null; public bool $ask = false; @@ -39,7 +41,6 @@ class ValidateAndInstall extends Component 'validateOS', 'validateDockerEngine', 'validateDockerVersion', - 'startProxy', 'refresh' => '$refresh', ]; @@ -50,7 +51,6 @@ public function init(int $data = 0) $this->docker_installed = null; $this->docker_version = null; $this->docker_compose_installed = null; - $this->proxy_started = null; $this->error = null; $this->number_of_tries = $data; if (! $this->ask) { @@ -64,27 +64,22 @@ public function startValidatingAfterAsking() $this->init(); } - public function startProxy() + public function retry() { - try { - $shouldStart = CheckProxy::run($this->server); - if ($shouldStart) { - $proxy = StartProxy::run($this->server, false); - if ($proxy === 'OK') { - $this->proxy_started = true; - } else { - throw new \Exception('Proxy could not be started.'); - } - } else { - $this->proxy_started = true; - } - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->authorize('update', $this->server); + $this->uptime = null; + $this->supported_os_type = null; + $this->docker_installed = null; + $this->docker_compose_installed = null; + $this->docker_version = null; + $this->error = null; + $this->number_of_tries = 0; + $this->init(); } public function validateConnection() { + $this->authorize('update', $this->server); ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; @@ -128,7 +123,7 @@ public function validateDockerEngine() if ($this->number_of_tries <= $this->max_tries) { $activity = $this->server->installDocker(); $this->number_of_tries++; - $this->dispatch('newActivityMonitor', $activity->id, 'init', $this->number_of_tries); + $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries); } return; @@ -155,12 +150,21 @@ public function validateDockerVersion() } else { $this->docker_version = $this->server->validateDockerEngineVersion(); if ($this->docker_version) { + // Mark validation as complete + $this->server->update(['is_validating' => false]); + $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); - $this->dispatch('success', 'Server validated.'); + ServerValidated::dispatch($this->server->team_id, $this->server->uuid); + $this->dispatch('success', 'Server validated, proxy is starting in a moment.'); + $proxyShouldRun = CheckProxy::run($this->server, true); + if (! $proxyShouldRun) { + return; + } + StartProxy::dispatch($this->server); } else { $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); - $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: documentation.'; + $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; $this->server->update([ 'validation_logs' => $this->error, ]); @@ -172,7 +176,6 @@ public function validateDockerVersion() if ($this->server->isBuildServer()) { return; } - $this->dispatch('startProxy'); } public function render() diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php new file mode 100644 index 000000000..832123d5a --- /dev/null +++ b/app/Livewire/Settings/Advanced.php @@ -0,0 +1,197 @@ + 'required', + 'is_registration_enabled' => 'boolean', + 'do_not_track' => 'boolean', + 'is_dns_validation_enabled' => 'boolean', + 'custom_dns_servers' => 'nullable|string', + 'is_api_enabled' => 'boolean', + 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], + 'is_sponsorship_popup_enabled' => 'boolean', + 'disable_two_step_confirmation' => 'boolean', + ]; + } + + public function mount() + { + if (! isInstanceAdmin()) { + return redirect()->route('dashboard'); + } + $this->server = Server::findOrFail(0); + $this->settings = instanceSettings(); + $this->custom_dns_servers = $this->settings->custom_dns_servers; + $this->allowed_ips = $this->settings->allowed_ips; + $this->do_not_track = $this->settings->do_not_track; + $this->is_registration_enabled = $this->settings->is_registration_enabled; + $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; + $this->is_api_enabled = $this->settings->is_api_enabled; + $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; + $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; + } + + public function submit() + { + try { + $this->validate(); + + $this->custom_dns_servers = str($this->custom_dns_servers)->replaceEnd(',', '')->trim(); + $this->custom_dns_servers = str($this->custom_dns_servers)->trim()->explode(',')->map(function ($dns) { + return str($dns)->trim()->lower(); + })->unique()->implode(','); + + // Handle allowed IPs with subnet support and 0.0.0.0 special case + $this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim(); + + // Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere) + $allowsFromAnywhere = false; + if (empty($this->allowed_ips)) { + $allowsFromAnywhere = true; + } elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) { + $allowsFromAnywhere = true; + } + + // Check if it's 0.0.0.0 (allow all) or empty + if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) { + // Keep as is - empty means no restriction, 0.0.0.0 means allow all + } else { + // Validate and clean up the entries + $invalidEntries = []; + $validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) { + $entry = str($entry)->trim()->toString(); + + if (empty($entry)) { + return null; + } + + // Check if it's valid CIDR notation + if (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) { + return $entry; + } + $invalidEntries[] = $entry; + + return null; + } + + // Check if it's a valid IP address + if (filter_var($entry, FILTER_VALIDATE_IP)) { + return $entry; + } + + $invalidEntries[] = $entry; + + return null; + })->filter()->unique(); + + if (! empty($invalidEntries)) { + $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); + + return; + } + + // Also check if we have no valid entries after filtering + if ($validEntries->isEmpty()) { + $this->dispatch('error', 'No valid IP addresses or subnets provided'); + + return; + } + + $this->allowed_ips = $validEntries->implode(','); + } + + $this->instantSave(); + + // Show security warning if allowing access from anywhere + if ($allowsFromAnywhere) { + $message = empty($this->allowed_ips) + ? 'Empty IP allowlist allows API access from anywhere.

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

This is not recommended for production environments!'; + $this->dispatch('warning', $message); + } + } catch (\Exception $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $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; + $this->settings->custom_dns_servers = $this->custom_dns_servers; + $this->settings->is_api_enabled = $this->is_api_enabled; + $this->settings->allowed_ips = $this->allowed_ips; + $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; + $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; + $this->settings->save(); + $this->dispatch('success', 'Settings updated!'); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + + public function toggleTwoStepConfirmation($password): bool + { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return false; + } + + $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation = true; + $this->settings->save(); + $this->dispatch('success', 'Two step confirmation has been disabled.'); + + return true; + } + + public function render() + { + return view('livewire.settings.advanced'); + } +} diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 3d90024b7..13d690352 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -2,11 +2,8 @@ namespace App\Livewire\Settings; -use App\Jobs\CheckForUpdatesJob; use App\Models\InstanceSettings; use App\Models\Server; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; @@ -15,10 +12,7 @@ class Index extends Component { public InstanceSettings $settings; - protected Server $server; - - #[Validate('boolean')] - public bool $is_auto_update_enabled; + public Server $server; #[Validate('nullable|string|max:255')] public ?string $fqdn = null; @@ -29,44 +23,23 @@ class Index extends Component #[Validate('required|integer|min:1025|max:65535')] public int $public_port_max; - #[Validate('nullable|string')] - public ?string $custom_dns_servers = null; - #[Validate('nullable|string|max:255')] public ?string $instance_name = null; - #[Validate('nullable|string')] - public ?string $allowed_ips = null; - #[Validate('nullable|string')] public ?string $public_ipv4 = null; #[Validate('nullable|string')] public ?string $public_ipv6 = null; - #[Validate('string')] - public string $auto_update_frequency; - - #[Validate('string|required')] - public string $update_check_frequency; - #[Validate('required|string|timezone')] public string $instance_timezone; - #[Validate('boolean')] - public bool $do_not_track; + public array $domainConflicts = []; - #[Validate('boolean')] - public bool $is_registration_enabled; + public bool $showDomainConflictModal = false; - #[Validate('boolean')] - public bool $is_dns_validation_enabled; - - #[Validate('boolean')] - public bool $is_api_enabled; - - #[Validate('boolean')] - public bool $disable_two_step_confirmation; + public bool $forceSaveDomains = false; public function render() { @@ -77,26 +50,16 @@ public function mount() { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); - } else { - $this->settings = instanceSettings(); - $this->fqdn = $this->settings->fqdn; - $this->public_port_min = $this->settings->public_port_min; - $this->public_port_max = $this->settings->public_port_max; - $this->custom_dns_servers = $this->settings->custom_dns_servers; - $this->instance_name = $this->settings->instance_name; - $this->allowed_ips = $this->settings->allowed_ips; - $this->public_ipv4 = $this->settings->public_ipv4; - $this->public_ipv6 = $this->settings->public_ipv6; - $this->do_not_track = $this->settings->do_not_track; - $this->is_auto_update_enabled = $this->settings->is_auto_update_enabled; - $this->is_registration_enabled = $this->settings->is_registration_enabled; - $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; - $this->is_api_enabled = $this->settings->is_api_enabled; - $this->auto_update_frequency = $this->settings->auto_update_frequency; - $this->update_check_frequency = $this->settings->update_check_frequency; - $this->instance_timezone = $this->settings->instance_timezone; - $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; } + $this->settings = instanceSettings(); + $this->server = Server::findOrFail(0); + $this->fqdn = $this->settings->fqdn; + $this->public_port_min = $this->settings->public_port_min; + $this->public_port_max = $this->settings->public_port_max; + $this->instance_name = $this->settings->instance_name; + $this->public_ipv4 = $this->settings->public_ipv4; + $this->public_ipv6 = $this->settings->public_ipv6; + $this->instance_timezone = $this->settings->instance_timezone; } #[Computed] @@ -111,28 +74,12 @@ public function timezones(): array public function instantSave($isSave = true) { $this->validate(); - if ($this->settings->is_auto_update_enabled === true) { - $this->validate([ - 'auto_update_frequency' => ['required', 'string'], - ]); - } - $this->settings->fqdn = $this->fqdn; $this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_max = $this->public_port_max; - $this->settings->custom_dns_servers = $this->custom_dns_servers; $this->settings->instance_name = $this->instance_name; - $this->settings->allowed_ips = $this->allowed_ips; $this->settings->public_ipv4 = $this->public_ipv4; $this->settings->public_ipv6 = $this->public_ipv6; - $this->settings->do_not_track = $this->do_not_track; - $this->settings->is_auto_update_enabled = $this->is_auto_update_enabled; - $this->settings->is_registration_enabled = $this->is_registration_enabled; - $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; - $this->settings->is_api_enabled = $this->is_api_enabled; - $this->settings->auto_update_frequency = $this->auto_update_frequency; - $this->settings->update_check_frequency = $this->update_check_frequency; - $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; $this->settings->instance_timezone = $this->instance_timezone; if ($isSave) { $this->settings->save(); @@ -140,11 +87,17 @@ public function instantSave($isSave = true) } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { $error_show = false; - $this->server = Server::findOrFail(0); $this->resetErrorBag(); if (! validate_timezone($this->instance_timezone)) { @@ -161,46 +114,26 @@ public function submit() } $this->validate(); - if ($this->is_auto_update_enabled && ! validate_cron_expression($this->auto_update_frequency)) { - $this->dispatch('error', 'Invalid Cron / Human expression for Auto Update Frequency.'); - if (empty($this->auto_update_frequency)) { - $this->auto_update_frequency = '0 0 * * *'; - } - - return; - } - - if (! validate_cron_expression($this->update_check_frequency)) { - $this->dispatch('error', 'Invalid Cron / Human expression for Update Check Frequency.'); - if (empty($this->update_check_frequency)) { - $this->update_check_frequency = '0 * * * *'; - } - - return; - } - - if ($this->settings->is_dns_validation_enabled && $this->settings->fqdn) { - if (! validate_dns_entry($this->settings->fqdn, $this->server)) { - $this->dispatch('error', "Validating DNS failed.

Make sure you have added the DNS records correctly.

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

Check this documentation for further help."); + if ($this->settings->is_dns_validation_enabled && $this->fqdn) { + if (! validateDNSEntry($this->fqdn, $this->server)) { + $this->dispatch('error', "Validating DNS failed.

Make sure you have added the DNS records correctly.

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

Check this documentation for further help."); $error_show = true; } } - if ($this->settings->fqdn) { - check_domain_usage(domain: $this->settings->fqdn); - } - $this->settings->custom_dns_servers = str($this->settings->custom_dns_servers)->replaceEnd(',', '')->trim(); - $this->settings->custom_dns_servers = str($this->settings->custom_dns_servers)->trim()->explode(',')->map(function ($dns) { - return str($dns)->trim()->lower(); - }); - $this->settings->custom_dns_servers = $this->settings->custom_dns_servers->unique(); - $this->settings->custom_dns_servers = $this->settings->custom_dns_servers->implode(','); + if ($this->fqdn) { + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(domain: $this->fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; - $this->settings->allowed_ips = str($this->settings->allowed_ips)->replaceEnd(',', '')->trim(); - $this->settings->allowed_ips = str($this->settings->allowed_ips)->trim()->explode(',')->map(function ($ip) { - return str($ip)->trim(); - }); - $this->settings->allowed_ips = $this->settings->allowed_ips->unique(); - $this->settings->allowed_ips = $this->settings->allowed_ips->implode(','); + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + } $this->instantSave(isSave: false); @@ -213,31 +146,4 @@ public function submit() return handleError($e, $this); } } - - public function checkManually() - { - CheckForUpdatesJob::dispatchSync(); - $this->dispatch('updateAvailable'); - $settings = instanceSettings(); - if ($settings->new_version_available) { - $this->dispatch('success', 'New version available!'); - } else { - $this->dispatch('success', 'No new version available.'); - } - } - - public function toggleTwoStepConfirmation($password): bool - { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return false; - } - - $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation = true; - $this->settings->save(); - $this->dispatch('success', 'Two step confirmation has been disabled.'); - - return true; - } } diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php new file mode 100644 index 000000000..fe20763b6 --- /dev/null +++ b/app/Livewire/Settings/Updates.php @@ -0,0 +1,101 @@ +server = Server::findOrFail(0); + + $this->settings = instanceSettings(); + $this->auto_update_frequency = $this->settings->auto_update_frequency; + $this->update_check_frequency = $this->settings->update_check_frequency; + $this->is_auto_update_enabled = $this->settings->is_auto_update_enabled; + } + + public function instantSave() + { + try { + if ($this->settings->is_auto_update_enabled === true) { + $this->validate([ + 'auto_update_frequency' => ['required', 'string'], + ]); + } + $this->settings->auto_update_frequency = $this->auto_update_frequency; + $this->settings->update_check_frequency = $this->update_check_frequency; + $this->settings->is_auto_update_enabled = $this->is_auto_update_enabled; + $this->settings->save(); + $this->dispatch('success', 'Settings updated!'); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + + public function submit() + { + try { + $this->resetErrorBag(); + $this->validate(); + + if ($this->is_auto_update_enabled && ! validate_cron_expression($this->auto_update_frequency)) { + $this->dispatch('error', 'Invalid Cron / Human expression for Auto Update Frequency.'); + if (empty($this->auto_update_frequency)) { + $this->auto_update_frequency = '0 0 * * *'; + } + + return; + } + + if (! validate_cron_expression($this->update_check_frequency)) { + $this->dispatch('error', 'Invalid Cron / Human expression for Update Check Frequency.'); + if (empty($this->update_check_frequency)) { + $this->update_check_frequency = '0 * * * *'; + } + + return; + } + + $this->instantSave(); + $this->server->setupDynamicProxyConfiguration(); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + + public function checkManually() + { + CheckForUpdatesJob::dispatchSync(); + $this->dispatch('updateAvailable'); + $settings = instanceSettings(); + if ($settings->new_version_available) { + $this->dispatch('success', 'New version available!'); + } else { + $this->dispatch('success', 'No new version available.'); + } + } + + public function render() + { + return view('livewire.settings.updates'); + } +} diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index bb5ed0aa8..84f5c6081 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -46,32 +46,31 @@ public function mount() { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); - } else { - $settings = instanceSettings(); - $this->server = Server::findOrFail(0); - $this->database = StandalonePostgresql::whereName('coolify-db')->first(); - $s3s = S3Storage::whereTeamId(0)->get() ?? []; - if ($this->database) { - $this->uuid = $this->database->uuid; - $this->name = $this->database->name; - $this->description = $this->database->description; - $this->postgres_user = $this->database->postgres_user; - $this->postgres_password = $this->database->postgres_password; - - if ($this->database->status !== 'running') { - $this->database->status = 'running'; - $this->database->save(); - } - $this->backup = $this->database->scheduledBackups->first(); - if ($this->backup && ! $this->server->isFunctional()) { - $this->backup->enabled = false; - $this->backup->save(); - } - $this->executions = $this->backup->executions; - } - $this->settings = $settings; - $this->s3s = $s3s; } + $settings = instanceSettings(); + $this->server = Server::findOrFail(0); + $this->database = StandalonePostgresql::whereName('coolify-db')->first(); + $s3s = S3Storage::whereTeamId(0)->get() ?? []; + if ($this->database) { + $this->uuid = $this->database->uuid; + $this->name = $this->database->name; + $this->description = $this->database->description; + $this->postgres_user = $this->database->postgres_user; + $this->postgres_password = $this->database->postgres_password; + + if ($this->database->status !== 'running') { + $this->database->status = 'running'; + $this->database->save(); + } + $this->backup = $this->database->scheduledBackups->first(); + if ($this->backup && ! $this->server->isFunctional()) { + $this->backup->enabled = false; + $this->backup->save(); + } + $this->executions = $this->backup->executions; + } + $this->settings = $settings; + $this->s3s = $s3s; } public function addCoolifyDatabase() @@ -121,6 +120,8 @@ public function addCoolifyDatabase() public function submit() { + $this->validate(); + $this->database->update([ 'name' => $this->name, 'description' => $this->description, diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php new file mode 100644 index 000000000..7afa763df --- /dev/null +++ b/app/Livewire/SettingsDropdown.php @@ -0,0 +1,73 @@ +getUnreadChangelogCount(); + } + + public function getEntriesProperty() + { + $user = Auth::user(); + + return app(ChangelogService::class)->getEntriesForUser($user); + } + + public function getCurrentVersionProperty() + { + return 'v'.config('constants.coolify.version'); + } + + public function openWhatsNewModal() + { + $this->showWhatsNewModal = true; + } + + public function closeWhatsNewModal() + { + $this->showWhatsNewModal = false; + } + + public function markAsRead($identifier) + { + app(ChangelogService::class)->markAsReadForUser($identifier, Auth::user()); + } + + public function markAllAsRead() + { + app(ChangelogService::class)->markAllAsReadForUser(Auth::user()); + } + + public function manualFetchChangelog() + { + if (! isDev()) { + return; + } + + try { + PullChangelog::dispatch(); + $this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.'); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage()); + } + } + + public function render() + { + return view('livewire.settings-dropdown', [ + 'entries' => $this->entries, + 'unreadCount' => $this->unreadCount, + 'currentVersion' => $this->currentVersion, + ]); + } +} diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 73e8c7398..ca48e9b16 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -225,7 +225,7 @@ public function sendTestEmail() 'test-email:'.$this->team->id, $perMinute = 0, function () { - $this->team?->notify(new Test($this->testEmailAddress)); + $this->team?->notifyNow(new Test($this->testEmailAddress)); $this->dispatch('success', 'Test Email sent.'); }, $decaySeconds = 10, diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index e88ac5f13..bee757a64 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -4,10 +4,13 @@ use App\Models\Application; use App\Models\Project; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Project $project; public Application $application; @@ -21,6 +24,8 @@ class Show extends Component public function saveKey($data) { try { + $this->authorize('update', $this->environment); + $found = $this->environment->environment_variables()->where('key', $data['key'])->first(); if ($found) { throw new \Exception('Variable already exists.'); diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 0171283c4..712a9960b 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -3,10 +3,13 @@ namespace App\Livewire\SharedVariables\Project; use App\Models\Project; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Project $project; protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh']; @@ -14,6 +17,8 @@ class Show extends Component public function saveKey($data) { try { + $this->authorize('update', $this->project); + $found = $this->project->environment_variables()->where('key', $data['key'])->first(); if ($found) { throw new \Exception('Variable already exists.'); diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index a76ccf58a..82473528c 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -3,10 +3,13 @@ namespace App\Livewire\SharedVariables\Team; use App\Models\Team; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public Team $team; protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh']; @@ -14,6 +17,8 @@ class Index extends Component public function saveKey($data) { try { + $this->authorize('update', $this->team); + $found = $this->team->environment_variables()->where('key', $data['key'])->first(); if ($found) { throw new \Exception('Variable already exists.'); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index e73c9dc73..351407dac 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -5,6 +5,7 @@ use App\Jobs\GithubAppPermissionJob; use App\Models\GithubApp; use App\Models\PrivateKey; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; @@ -13,7 +14,9 @@ class Change extends Component { - public string $webhook_endpoint; + use AuthorizesRequests; + + public string $webhook_endpoint = ''; public ?string $ipv4 = null; @@ -31,32 +34,60 @@ class Change extends Component public ?GithubApp $github_app = null; + // Explicit properties public string $name; - public bool $is_system_wide; + public ?string $organization = null; + + public string $apiUrl; + + public string $htmlUrl; + + public string $customUser; + + public int $customPort; + + public int $appId; + + public int $installationId; + + public string $clientId; + + public string $clientSecret; + + public string $webhookSecret; + + public bool $isSystemWide; + + public int $privateKeyId; + + public ?string $contents = null; + + public ?string $metadata = null; + + public ?string $pullRequests = null; public $applications; public $privateKeys; protected $rules = [ - 'github_app.name' => 'required|string', - 'github_app.organization' => 'nullable|string', - 'github_app.api_url' => 'required|string', - 'github_app.html_url' => 'required|string', - 'github_app.custom_user' => 'required|string', - 'github_app.custom_port' => 'required|int', - 'github_app.app_id' => 'required|int', - 'github_app.installation_id' => 'required|int', - 'github_app.client_id' => 'required|string', - 'github_app.client_secret' => 'required|string', - 'github_app.webhook_secret' => 'required|string', - 'github_app.is_system_wide' => 'required|bool', - 'github_app.contents' => 'nullable|string', - 'github_app.metadata' => 'nullable|string', - 'github_app.pull_requests' => 'nullable|string', - 'github_app.administration' => 'nullable|string', - 'github_app.private_key_id' => 'required|int', + 'name' => 'required|string', + 'organization' => 'nullable|string', + 'apiUrl' => 'required|string', + 'htmlUrl' => 'required|string', + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'appId' => 'required|int', + 'installationId' => 'required|int', + 'clientId' => 'required|string', + 'clientSecret' => 'required|string', + 'webhookSecret' => 'required|string', + 'isSystemWide' => 'required|bool', + 'contents' => 'nullable|string', + 'metadata' => 'nullable|string', + 'pullRequests' => 'nullable|string', + 'privateKeyId' => 'required|int', ]; public function boot() @@ -66,9 +97,57 @@ public function boot() } } + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->github_app->name = $this->name; + $this->github_app->organization = $this->organization; + $this->github_app->api_url = $this->apiUrl; + $this->github_app->html_url = $this->htmlUrl; + $this->github_app->custom_user = $this->customUser; + $this->github_app->custom_port = $this->customPort; + $this->github_app->app_id = $this->appId; + $this->github_app->installation_id = $this->installationId; + $this->github_app->client_id = $this->clientId; + $this->github_app->client_secret = $this->clientSecret; + $this->github_app->webhook_secret = $this->webhookSecret; + $this->github_app->is_system_wide = $this->isSystemWide; + $this->github_app->private_key_id = $this->privateKeyId; + $this->github_app->contents = $this->contents; + $this->github_app->metadata = $this->metadata; + $this->github_app->pull_requests = $this->pullRequests; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->github_app->name; + $this->organization = $this->github_app->organization; + $this->apiUrl = $this->github_app->api_url; + $this->htmlUrl = $this->github_app->html_url; + $this->customUser = $this->github_app->custom_user; + $this->customPort = $this->github_app->custom_port; + $this->appId = $this->github_app->app_id; + $this->installationId = $this->github_app->installation_id; + $this->clientId = $this->github_app->client_id; + $this->clientSecret = $this->github_app->client_secret; + $this->webhookSecret = $this->github_app->webhook_secret; + $this->isSystemWide = $this->github_app->is_system_wide; + $this->privateKeyId = $this->github_app->private_key_id; + $this->contents = $this->github_app->contents; + $this->metadata = $this->github_app->metadata; + $this->pullRequests = $this->github_app->pull_requests; + } + } + public function checkPermissions() { try { + $this->authorize('view', $this->github_app); + GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->dispatch('success', 'Github App permissions updated.'); @@ -121,6 +200,10 @@ public function mount() $this->applications = $this->github_app->applications; $settings = instanceSettings(); + // Sync data from model to properties + $this->syncData(false); + + // Override name with kebab case for display $this->name = str($this->github_app->name)->kebab(); $this->fqdn = $settings->fqdn; @@ -155,7 +238,7 @@ public function mount() if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->ipv4; + $this->webhook_endpoint = $this->ipv4 ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { @@ -195,6 +278,8 @@ private function generateGithubJwt($private_key, $app_id): string public function updateGithubAppName() { try { + $this->authorize('update', $this->github_app); + $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); if (! $privateKey) { @@ -237,22 +322,12 @@ public function updateGithubAppName() public function submit() { try { + $this->authorize('update', $this->github_app); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->validate([ - 'github_app.name' => 'required|string', - 'github_app.organization' => 'nullable|string', - 'github_app.api_url' => 'required|string', - 'github_app.html_url' => 'required|string', - 'github_app.custom_user' => 'required|string', - 'github_app.custom_port' => 'required|int', - 'github_app.app_id' => 'required|int', - 'github_app.installation_id' => 'required|int', - 'github_app.client_id' => 'required|string', - 'github_app.client_secret' => 'required|string', - 'github_app.webhook_secret' => 'required|string', - 'github_app.is_system_wide' => 'required|bool', - 'github_app.private_key_id' => 'required|int', - ]); + $this->validate(); + + $this->syncData(true); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); } catch (\Throwable $e) { @@ -262,6 +337,8 @@ public function submit() public function createGithubAppManually() { + $this->authorize('update', $this->github_app); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->app_id = '1234567890'; $this->github_app->installation_id = '1234567890'; @@ -272,7 +349,11 @@ public function createGithubAppManually() public function instantSave() { try { + $this->authorize('update', $this->github_app); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + + $this->syncData(true); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); } catch (\Throwable $e) { @@ -283,6 +364,8 @@ public function instantSave() public function delete() { try { + $this->authorize('delete', $this->github_app); + if ($this->github_app->applications->isNotEmpty()) { $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 136d3525e..f5d851b64 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -3,10 +3,13 @@ namespace App\Livewire\Source\Github; use App\Models\GithubApp; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Create extends Component { + use AuthorizesRequests; + public string $name; public ?string $organization = null; @@ -29,6 +32,8 @@ public function mount() public function createGitHubApp() { try { + $this->authorize('createAnyResource'); + $this->validate([ 'name' => 'required|string', 'organization' => 'nullable|string', diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php deleted file mode 100644 index 34600bb7d..000000000 --- a/app/Livewire/Source/Gitlab/Change.php +++ /dev/null @@ -1,13 +0,0 @@ - 'required|min:3|max:255', - 'description' => 'nullable|min:3|max:255', - 'region' => 'required|max:255', - 'key' => 'required|max:255', - 'secret' => 'required|max:255', - 'bucket' => 'required|max:255', - 'endpoint' => 'required|url|max:255', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'region' => 'required|max:255', + 'key' => 'required|max:255', + 'secret' => 'required|max:255', + 'bucket' => 'required|max:255', + 'endpoint' => 'required|url|max:255', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'region.required' => 'The Region field is required.', + 'region.max' => 'The Region may not be greater than 255 characters.', + 'key.required' => 'The Access Key field is required.', + 'key.max' => 'The Access Key may not be greater than 255 characters.', + 'secret.required' => 'The Secret Key field is required.', + 'secret.max' => 'The Secret Key may not be greater than 255 characters.', + 'bucket.required' => 'The Bucket field is required.', + 'bucket.max' => 'The Bucket may not be greater than 255 characters.', + 'endpoint.required' => 'The Endpoint field is required.', + 'endpoint.url' => 'The Endpoint must be a valid URL.', + 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + ] + ); + } protected $validationAttributes = [ 'name' => 'Name', @@ -70,6 +97,8 @@ public function updatedEndpoint($value) public function submit() { try { + $this->authorize('create', S3Storage::class); + $this->validate(); $this->storage = new S3Storage; $this->storage->name = $this->name; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 8ca0020c7..d97550693 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -3,37 +3,121 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Form extends Component { + use AuthorizesRequests; + public S3Storage $storage; - protected $rules = [ - 'storage.is_usable' => 'nullable|boolean', - 'storage.name' => 'nullable|min:3|max:255', - 'storage.description' => 'nullable|min:3|max:255', - 'storage.region' => 'required|max:255', - 'storage.key' => 'required|max:255', - 'storage.secret' => 'required|max:255', - 'storage.bucket' => 'required|max:255', - 'storage.endpoint' => 'required|url|max:255', - ]; + // Explicit properties + public ?string $name = null; + + public ?string $description = null; + + public string $endpoint; + + public string $bucket; + + public string $region; + + public string $key; + + public string $secret; + + public ?bool $isUsable = null; + + protected function rules(): array + { + return [ + 'isUsable' => 'nullable|boolean', + 'name' => ValidationPatterns::nameRules(required: false), + 'description' => ValidationPatterns::descriptionRules(), + 'region' => 'required|max:255', + 'key' => 'required|max:255', + 'secret' => 'required|max:255', + 'bucket' => 'required|max:255', + 'endpoint' => 'required|url|max:255', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'region.required' => 'The Region field is required.', + 'region.max' => 'The Region may not be greater than 255 characters.', + 'key.required' => 'The Access Key field is required.', + 'key.max' => 'The Access Key may not be greater than 255 characters.', + 'secret.required' => 'The Secret Key field is required.', + 'secret.max' => 'The Secret Key may not be greater than 255 characters.', + 'bucket.required' => 'The Bucket field is required.', + 'bucket.max' => 'The Bucket may not be greater than 255 characters.', + 'endpoint.required' => 'The Endpoint field is required.', + 'endpoint.url' => 'The Endpoint must be a valid URL.', + 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + ] + ); + } protected $validationAttributes = [ - 'storage.is_usable' => 'Is Usable', - 'storage.name' => 'Name', - 'storage.description' => 'Description', - 'storage.region' => 'Region', - 'storage.key' => 'Key', - 'storage.secret' => 'Secret', - 'storage.bucket' => 'Bucket', - 'storage.endpoint' => 'Endpoint', + 'isUsable' => 'Is Usable', + 'name' => 'Name', + 'description' => 'Description', + 'region' => 'Region', + 'key' => 'Key', + 'secret' => 'Secret', + 'bucket' => 'Bucket', + 'endpoint' => 'Endpoint', ]; - public function test_s3_connection() + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->storage->name = $this->name; + $this->storage->description = $this->description; + $this->storage->endpoint = $this->endpoint; + $this->storage->bucket = $this->bucket; + $this->storage->region = $this->region; + $this->storage->key = $this->key; + $this->storage->secret = $this->secret; + $this->storage->is_usable = $this->isUsable; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->storage->name; + $this->description = $this->storage->description; + $this->endpoint = $this->storage->endpoint; + $this->bucket = $this->storage->bucket; + $this->region = $this->storage->region; + $this->key = $this->storage->key; + $this->secret = $this->storage->secret; + $this->isUsable = $this->storage->is_usable; + } + } + + public function mount() + { + $this->syncData(false); + } + + public function testConnection() { try { + $this->authorize('validateConnection', $this->storage); + $this->storage->testConnection(shouldSave: true); return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); @@ -45,6 +129,8 @@ public function test_s3_connection() public function delete() { try { + $this->authorize('delete', $this->storage); + $this->storage->delete(); return redirect()->route('storage.index'); @@ -55,10 +141,34 @@ public function delete() public function submit() { - $this->validate(); try { - $this->test_s3_connection(); + $this->authorize('update', $this->storage); + + DB::transaction(function () { + $this->validate(); + + // Sync properties to model before saving + $this->syncData(true); + $this->storage->save(); + + // Test connection with new values - if this fails, transaction will rollback + $this->storage->testConnection(shouldSave: false); + + // If we get here, the connection test succeeded + $this->storage->is_usable = true; + $this->storage->unusable_email_sent = false; + $this->storage->save(); + + // Update local property to reflect success + $this->isUsable = true; + }); + + $this->dispatch('success', 'Storage settings updated and connection verified.'); } catch (\Throwable $e) { + // Refresh the model to revert UI to database values after rollback + $this->storage->refresh(); + $this->syncData(false); + return handleError($e, $this); } } diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index 8a9cc456f..ac37cca05 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -75,7 +75,7 @@ public function getStripeStatus() } } catch (\Exception $e) { // Log the error - logger()->error('Stripe API error: ' . $e->getMessage()); + logger()->error('Stripe API error: '.$e->getMessage()); // Set a flag to show an error message to the user $this->addError('stripe', 'Could not retrieve subscription information. Please try again later.'); } finally { diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index cfb47d9d8..6d6915ae2 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -3,7 +3,6 @@ namespace App\Livewire\Team; use App\Models\InstanceSettings; -use App\Models\Team; use App\Models\User; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -53,30 +52,12 @@ public function getUsers() } } - private function finalizeDeletion(User $user, Team $team) - { - $servers = $team->servers; - foreach ($servers as $server) { - $resources = $server->definedResources(); - foreach ($resources as $resource) { - $resource->forceDelete(); - } - $server->forceDelete(); - } - - $projects = $team->projects; - foreach ($projects as $project) { - $project->forceDelete(); - } - $team->members()->detach($user->id); - $team->delete(); - } - public function delete($id, $password) { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); } + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); @@ -84,52 +65,22 @@ public function delete($id, $password) return; } } + if (! auth()->user()->isInstanceAdmin()) { return $this->dispatch('error', 'You are not authorized to delete users'); } + $user = User::find($id); - $teams = $user->teams; - foreach ($teams as $team) { - $user_alone_in_team = $team->members->count() === 1; - if ($team->id === 0) { - if ($user_alone_in_team) { - return $this->dispatch('error', 'User is alone in the root team, cannot delete'); - } - } - if ($user_alone_in_team) { - $this->finalizeDeletion($user, $team); - - continue; - } - if ($user->isOwner()) { - $found_other_owner_or_admin = $team->members->filter(function ($member) { - return $member->pivot->role === 'owner' || $member->pivot->role === 'admin'; - })->where('id', '!=', $user->id)->first(); - - if ($found_other_owner_or_admin) { - $team->members()->detach($user->id); - - continue; - } else { - $found_other_member_who_is_not_owner = $team->members->filter(function ($member) { - return $member->pivot->role === 'member'; - })->first(); - if ($found_other_member_who_is_not_owner) { - $found_other_member_who_is_not_owner->pivot->role = 'owner'; - $found_other_member_who_is_not_owner->pivot->save(); - $team->members()->detach($user->id); - } else { - $this->finalizeDeletion($user, $team); - } - - continue; - } - } else { - $team->members()->detach($user->id); - } + if (! $user) { + return $this->dispatch('error', 'User not found'); + } + + try { + $user->delete(); + $this->getUsers(); + } catch (\Exception $e) { + return $this->dispatch('error', $e->getMessage()); } - $user->delete(); - $this->getUsers(); } public function render() diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index f805d6122..cd15be67d 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -3,17 +3,28 @@ namespace App\Livewire\Team; use App\Models\Team; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; class Create extends Component { - #[Validate(['required', 'min:3', 'max:255'])] public string $name = ''; - #[Validate(['nullable', 'min:3', 'max:255'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function submit() { try { @@ -24,7 +35,7 @@ public function submit() 'personal_team' => false, ]); auth()->user()->teams()->attach($team, ['role' => 'admin']); - refreshSession(); + refreshSession($team); return redirect()->route('team.index'); } catch (\Throwable $e) { diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 0972e7364..e4daad311 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -4,29 +4,72 @@ use App\Models\Team; use App\Models\TeamInvitation; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public $invitations = []; public Team $team; - protected $rules = [ - 'team.name' => 'required|min:3|max:255', - 'team.description' => 'nullable|min:3|max:255', - ]; + // Explicit properties + public string $name; + + public ?string $description = null; + + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'name.required' => 'The Name field is required.', + 'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + ] + ); + } protected $validationAttributes = [ - 'team.name' => 'name', - 'team.description' => 'description', + 'name' => 'name', + 'description' => 'description', ]; + /** + * Sync data between component properties and model + * + * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. + */ + private function syncData(bool $toModel = false): void + { + if ($toModel) { + // Sync TO model (before save) + $this->team->name = $this->name; + $this->team->description = $this->description; + } else { + // Sync FROM model (on load/refresh) + $this->name = $this->team->name; + $this->description = $this->team->description; + } + } + public function mount() { $this->team = currentTeam(); + $this->syncData(false); if (auth()->user()->isAdminFromSession()) { $this->invitations = TeamInvitation::whereTeamId(currentTeam()->id)->get(); @@ -42,6 +85,8 @@ public function submit() { $this->validate(); try { + $this->authorize('update', $this->team); + $this->syncData(true); $this->team->save(); refreshSession(); $this->dispatch('success', 'Team updated.'); @@ -53,6 +98,7 @@ public function submit() public function delete() { $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); $currentTeam->delete(); $currentTeam->members->each(function ($user) use ($currentTeam) { diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 93432efc8..523f640b9 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -3,10 +3,14 @@ namespace App\Livewire\Team; use App\Models\TeamInvitation; +use App\Models\User; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Invitations extends Component { + use AuthorizesRequests; + public $invitations; protected $listeners = ['refreshInvitations']; @@ -14,8 +18,15 @@ class Invitations extends Component public function deleteInvitation(int $invitation_id) { try { - $initiation_found = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); - $initiation_found->delete(); + $this->authorize('manageInvitations', currentTeam()); + + $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); + $user = User::whereEmail($invitation->email)->first(); + if (filled($user)) { + $user->deleteIfNotVerifiedAndForcePasswordReset(); + } + + $invitation->delete(); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 25f8a1ff5..45af53950 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -4,6 +4,7 @@ use App\Models\TeamInvitation; use App\Models\User; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Hash; @@ -13,6 +14,8 @@ class InviteLink extends Component { + use AuthorizesRequests; + public string $email; public string $role = 'member'; @@ -29,21 +32,31 @@ public function mount() public function viaEmail() { - $this->generate_invite_link(sendEmail: true); + $this->generateInviteLink(sendEmail: true); } public function viaLink() { - $this->generate_invite_link(sendEmail: false); + $this->generateInviteLink(sendEmail: false); } - private function generate_invite_link(bool $sendEmail = false) + private function generateInviteLink(bool $sendEmail = false) { try { + $this->authorize('manageInvitations', currentTeam()); $this->validate(); - if (auth()->user()->role() === 'admin' && $this->role === 'owner') { + + // Prevent privilege escalation: users cannot invite someone with higher privileges + $userRole = auth()->user()->role(); + if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) { + throw new \Exception('Members cannot invite admins or owners.'); + } + if ($userRole === 'admin' && $this->role === 'owner') { throw new \Exception('Admins cannot invite owners.'); } + + $this->email = strtolower($this->email); + $member_emails = currentTeam()->members()->get()->pluck('email'); if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 890d640a0..96c98c637 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -4,16 +4,21 @@ use App\Enums\Role; use App\Models\User; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; use Livewire\Component; class Member extends Component { + use AuthorizesRequests; + public User $member; public function makeAdmin() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::ADMIN) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); @@ -28,6 +33,8 @@ public function makeAdmin() public function makeOwner() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::OWNER) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); @@ -42,6 +49,8 @@ public function makeOwner() public function makeReadonly() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::ADMIN) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); @@ -56,6 +65,8 @@ public function makeReadonly() public function remove() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::ADMIN) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); diff --git a/app/Livewire/Team/Member/Index.php b/app/Livewire/Team/Member/Index.php index 00b745fe4..e057ba3f6 100644 --- a/app/Livewire/Team/Member/Index.php +++ b/app/Livewire/Team/Member/Index.php @@ -3,15 +3,19 @@ namespace App\Livewire\Team\Member; use App\Models\TeamInvitation; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public $invitations = []; public function mount() { - if (auth()->user()->isAdminFromSession()) { + // Only load invitations for users who can manage them + if (auth()->user()->can('manageInvitations', currentTeam())) { $this->invitations = TeamInvitation::whereTeamId(currentTeam()->id)->get(); } } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index a24a237c5..6bb4c5e90 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -18,10 +18,9 @@ class Index extends Component public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } - $this->servers = Server::isReachable()->get(); + $this->servers = Server::isReachable()->get()->filter(function ($server) { + return $server->isTerminalEnabled(); + }); } public function loadContainers() @@ -57,11 +56,15 @@ private function getAllActiveContainers() return null; })->filter(); - }); + })->sortBy('name'); } public function updatedSelectedUuid() { + if ($this->selected_uuid === 'default') { + // When cleared to default, do nothing (no error message) + return; + } $this->connectToContainer(); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 94bd5c75b..9554d71a7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,7 +4,9 @@ use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -109,9 +111,9 @@ class Application extends BaseModel { - use HasConfiguration, HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; - private static $parserVersion = '4'; + private static $parserVersion = '5'; protected $guarded = []; @@ -122,6 +124,97 @@ class Application extends BaseModel 'http_basic_auth_password' => 'encrypted', ]; + protected static function booted() + { + static::addGlobalScope('withRelations', function ($builder) { + $builder->withCount([ + 'additional_servers', + 'additional_networks', + ]); + }); + static::saving(function ($application) { + $payload = []; + if ($application->isDirty('fqdn')) { + if ($application->fqdn === '') { + $application->fqdn = null; + } + $payload['fqdn'] = $application->fqdn; + } + if ($application->isDirty('install_command')) { + $payload['install_command'] = str($application->install_command)->trim(); + } + if ($application->isDirty('build_command')) { + $payload['build_command'] = str($application->build_command)->trim(); + } + if ($application->isDirty('start_command')) { + $payload['start_command'] = str($application->start_command)->trim(); + } + if ($application->isDirty('base_directory')) { + $payload['base_directory'] = str($application->base_directory)->trim(); + } + if ($application->isDirty('publish_directory')) { + $payload['publish_directory'] = str($application->publish_directory)->trim(); + } + if ($application->isDirty('git_repository')) { + $payload['git_repository'] = str($application->git_repository)->trim(); + } + if ($application->isDirty('git_branch')) { + $payload['git_branch'] = str($application->git_branch)->trim(); + } + if ($application->isDirty('git_commit_sha')) { + $payload['git_commit_sha'] = str($application->git_commit_sha)->trim(); + } + if ($application->isDirty('status')) { + $payload['last_online_at'] = now(); + } + if ($application->isDirty('custom_nginx_configuration')) { + if ($application->custom_nginx_configuration === '') { + $payload['custom_nginx_configuration'] = null; + } + } + if (count($payload) > 0) { + $application->forceFill($payload); + } + }); + static::created(function ($application) { + ApplicationSetting::create([ + 'application_id' => $application->id, + ]); + $application->compose_parsing_version = self::$parserVersion; + $application->save(); + + // Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications + if ($application->build_pack === 'nixpacks') { + EnvironmentVariable::create([ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_multiline' => false, + 'is_literal' => false, + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + } + }); + static::forceDeleting(function ($application) { + $application->update(['fqdn' => null]); + $application->settings()->delete(); + $application->persistentStorages()->delete(); + $application->environment_variables()->delete(); + $application->environment_variables_preview()->delete(); + foreach ($application->scheduled_tasks as $task) { + $task->delete(); + } + $application->tags()->detach(); + $application->previews()->delete(); + foreach ($application->deployment_queue as $deployment) { + $deployment->delete(); + } + }); + } + public function customNetworkAliases(): Attribute { return Attribute::make( @@ -182,73 +275,6 @@ private function isJson($string) return json_last_error() === JSON_ERROR_NONE; } - protected static function booted() - { - static::addGlobalScope('withRelations', function ($builder) { - $builder->withCount([ - 'additional_servers', - 'additional_networks', - ]); - }); - static::saving(function ($application) { - $payload = []; - if ($application->isDirty('fqdn')) { - if ($application->fqdn === '') { - $application->fqdn = null; - } - $payload['fqdn'] = $application->fqdn; - } - if ($application->isDirty('install_command')) { - $payload['install_command'] = str($application->install_command)->trim(); - } - if ($application->isDirty('build_command')) { - $payload['build_command'] = str($application->build_command)->trim(); - } - if ($application->isDirty('start_command')) { - $payload['start_command'] = str($application->start_command)->trim(); - } - if ($application->isDirty('base_directory')) { - $payload['base_directory'] = str($application->base_directory)->trim(); - } - if ($application->isDirty('publish_directory')) { - $payload['publish_directory'] = str($application->publish_directory)->trim(); - } - if ($application->isDirty('status')) { - $payload['last_online_at'] = now(); - } - if ($application->isDirty('custom_nginx_configuration')) { - if ($application->custom_nginx_configuration === '') { - $payload['custom_nginx_configuration'] = null; - } - } - if (count($payload) > 0) { - $application->forceFill($payload); - } - }); - static::created(function ($application) { - ApplicationSetting::create([ - 'application_id' => $application->id, - ]); - $application->compose_parsing_version = self::$parserVersion; - $application->save(); - }); - static::forceDeleting(function ($application) { - $application->update(['fqdn' => null]); - $application->settings()->delete(); - $application->persistentStorages()->delete(); - $application->environment_variables()->delete(); - $application->environment_variables_preview()->delete(); - foreach ($application->scheduled_tasks as $task) { - $task->delete(); - } - $application->tags()->detach(); - $application->previews()->delete(); - foreach ($application->deployment_queue as $deployment) { - $deployment->delete(); - } - }); - } - public static function ownedByCurrentTeamAPI(int $teamId) { return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); @@ -259,25 +285,15 @@ public static function ownedByCurrentTeam() return Application::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } - public function getContainersToStop(bool $previewDeployments = false): array + public function getContainersToStop(Server $server, bool $previewDeployments = false): array { $containers = $previewDeployments - ? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true) - : getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0); + ? getCurrentApplicationContainerStatus($server, $this->id, includePullrequests: true) + : getCurrentApplicationContainerStatus($server, $this->id, 0); return $containers->pluck('Names')->toArray(); } - public function stopContainers(array $containerNames, $server, int $timeout = 30) - { - foreach ($containerNames as $containerName) { - instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", - "docker rm -f $containerName", - ], server: $server, throwError: false); - } - } - public function deleteConfigurations() { $server = data_get($this, 'destination.server'); @@ -737,7 +753,14 @@ public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() @@ -747,14 +770,6 @@ public function runtime_environment_variables() ->where('key', 'not like', 'NIXPACKS_%'); } - public function build_environment_variables() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', false) - ->where('is_build_time', true) - ->where('key', 'not like', 'NIXPACKS_%'); - } - public function nixpacks_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -766,7 +781,14 @@ public function environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); + ->orderByRaw(" + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables_preview() @@ -776,14 +798,6 @@ public function runtime_environment_variables_preview() ->where('key', 'not like', 'NIXPACKS_%'); } - public function build_environment_variables_preview() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->where('is_build_time', true) - ->where('key', 'not like', 'NIXPACKS_%'); - } - public function nixpacks_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -808,7 +822,7 @@ public function environment() public function previews() { - return $this->hasMany(ApplicationPreview::class); + return $this->hasMany(ApplicationPreview::class)->orderBy('pull_request_id', 'desc'); } public function deployment_queue() @@ -846,9 +860,14 @@ public function get_last_days_deployments() return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get(); } - public function deployments(int $skip = 0, int $take = 10) + public function deployments(int $skip = 0, int $take = 10, ?string $pullRequestId = null) { $deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc'); + + if ($pullRequestId) { + $deployments = $deployments->where('pull_request_id', $pullRequestId); + } + $count = $deployments->count(); $deployments = $deployments->skip($skip)->take($take)->get(); @@ -938,11 +957,11 @@ public function isLogDrainEnabled() public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -984,18 +1003,30 @@ public function dirOnServer() public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) { $baseDir = $this->generateBaseDir($deployment_uuid); + $escapedBaseDir = escapeshellarg($baseDir); + $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; if ($this->git_commit_sha !== 'HEAD') { - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + // If shallow clone is enabled and we need a specific commit, + // we need to fetch that specific commit with depth=1 + if ($isShallowCloneEnabled) { + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + } else { + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + } } if ($this->settings->is_git_submodules_enabled) { + // Check if .gitmodules file exists before running submodule commands + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then"; if ($public) { - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true"; + $git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&"; } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive"; + // Add shallow submodules flag if shallow clone is enabled + $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; + $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; } return $git_clone_command; @@ -1033,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $source_html_url_scheme = $url['scheme']; if ($this->source->getMorphClass() == 'App\Models\GithubApp') { + $escapedCustomRepository = escapeshellarg($customRepository); if ($this->source->is_public) { + $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; - $base_command = "{$base_command} {$this->source->html_url}/{$customRepository}"; + $base_command = "{$base_command} {$escapedRepoUrl}"; } else { $github_access_token = generateGithubInstallationToken($this->source); if ($exec_in_docker) { - $base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; - $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $base_command = "{$base_command} {$escapedRepoUrl}"; + $fullRepoUrl = $repoUrl; } else { - $base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; - $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $base_command = "{$base_command} {$escapedRepoUrl}"; + $fullRepoUrl = $repoUrl; } } @@ -1069,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); } $private_key = base64_encode($private_key); - $base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}"; + // When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context + // Replace ' with '\'' to safely escape within single-quoted bash strings + $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; if ($exec_in_docker) { $commands = collect([ @@ -1086,9 +1126,9 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_comamnd)); + $commands->push(executeInDocker($deployment_uuid, $base_command)); } else { - $commands->push($base_comamnd); + $commands->push($base_command); } return [ @@ -1100,7 +1140,8 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; - $base_command = "{$base_command} {$customRepository}"; + $escapedCustomRepository = escapeshellarg($customRepository); + $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $base_command)); @@ -1121,10 +1162,28 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid); + + // Escape shell arguments for safety to prevent command injection + $escapedBranch = escapeshellarg($branch); + $escapedBaseDir = escapeshellarg($baseDir); + $commands = collect([]); - $git_clone_command = "git clone -b \"{$this->git_branch}\""; + + // Check if shallow clone is enabled + $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + $depthFlag = $isShallowCloneEnabled ? ' --depth=1' : ''; + + $submoduleFlags = ''; + if ($this->settings->is_git_submodules_enabled) { + $submoduleFlags = ' --recurse-submodules'; + if ($isShallowCloneEnabled) { + $submoduleFlags .= ' --shallow-submodules'; + } + } + + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch}"; if ($only_checkout) { - $git_clone_command = "git clone --no-checkout -b \"{$this->git_branch}\""; + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch}"; } if ($pull_request_id !== 0) { $pr_branch_name = "pr-{$pull_request_id}-coolify"; @@ -1138,7 +1197,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($this->source->getMorphClass() === \App\Models\GithubApp::class) { if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; - $git_clone_command = "{$git_clone_command} {$this->source->html_url}/{$customRepository} {$baseDir}"; + $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); + $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; if (! $only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); } @@ -1150,11 +1210,15 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $github_access_token = generateGithubInstallationToken($this->source); if ($exec_in_docker) { - $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git {$baseDir}"; - $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; + $fullRepoUrl = $repoUrl; } else { - $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository} {$baseDir}"; - $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; + $fullRepoUrl = $repoUrl; } if (! $only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false); @@ -1169,10 +1233,11 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name); + $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "cd {$baseDir} && git fetch origin {$branch} && $git_checkout_command")); + $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); } else { - $commands->push("cd {$baseDir} && git fetch origin {$branch} && $git_checkout_command"); + $commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"); } } @@ -1190,7 +1255,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); } $private_key = base64_encode($private_key); - $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$customRepository} {$baseDir}"; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { @@ -1217,7 +1283,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1225,14 +1291,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); } } @@ -1250,7 +1316,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; - $git_clone_command = "{$git_clone_command} {$customRepository} {$baseDir}"; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); if ($pull_request_id !== 0) { @@ -1261,7 +1328,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1269,14 +1336,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); } } @@ -1299,7 +1366,7 @@ public function oldRawParser() try { $yaml = Yaml::parse($this->docker_compose_raw); } catch (\Exception $e) { - throw new \Exception($e->getMessage()); + throw new \RuntimeException($e->getMessage()); } $services = data_get($yaml, 'services'); @@ -1358,7 +1425,7 @@ public function oldRawParser() public function parse(int $pull_request_id = 0, ?int $preview_id = null) { if ((int) $this->compose_parsing_version >= 3) { - return newParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { @@ -1446,8 +1513,23 @@ public function loadComposeFile($isInit = false) $this->save(); $parsedServices = $this->parse(); if ($this->docker_compose_domains) { - $json = collect(json_decode($this->docker_compose_domains)); - $names = collect(data_get($parsedServices, 'services'))->keys()->toArray(); + $decoded = json_decode($this->docker_compose_domains, true); + $json = collect(is_array($decoded) ? $decoded : []); + $normalized = collect(); + foreach ($json as $key => $value) { + $normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_'); + $normalized->put($normalizedKey, $value); + } + $json = $normalized; + $services = collect(data_get($parsedServices, 'services', [])); + foreach ($services as $name => $service) { + if (str($name)->contains('-') || str($name)->contains('.')) { + $replacedName = str($name)->replace('-', '_')->replace('.', '_'); + $services->put((string) $replacedName, $service); + $services->forget((string) $name); + } + } + $names = collect($services)->keys()->toArray(); $jsonNames = $json->keys()->toArray(); $diff = array_diff($jsonNames, $names); $json = $json->filter(function ($value, $key) use ($diff) { @@ -1510,28 +1592,206 @@ protected function buildGitCheckoutCommand($target): string return $command; } + private function parseWatchPaths($value) + { + if ($value) { + $watch_paths = collect(explode("\n", $value)) + ->map(function (string $path): string { + // Trim whitespace + $path = trim($path); + + if (str_starts_with($path, '!')) { + $negation = '!'; + $pathWithoutNegation = substr($path, 1); + $pathWithoutNegation = ltrim(trim($pathWithoutNegation), '/'); + + return $negation.$pathWithoutNegation; + } + + return ltrim($path, '/'); + }) + ->filter(function (string $path): bool { + return strlen($path) > 0; + }); + + return trim($watch_paths->implode("\n")); + } + } + public function watchPaths(): Attribute { return Attribute::make( set: function ($value) { if ($value) { - return trim($value); + return $this->parseWatchPaths($value); } } ); } + public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + return self::matchPaths($modified_files, $watch_paths); + } + + /** + * Static method to match paths against watch patterns with negation support + * Uses order-based matching: last matching pattern wins + */ + public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + if (is_null($watch_paths) || $watch_paths->isEmpty()) { + return collect([]); + } + + return $modified_files->filter(function ($file) use ($watch_paths) { + $shouldInclude = null; // null means no patterns matched + + // Process patterns in order - last match wins + foreach ($watch_paths as $pattern) { + $pattern = trim($pattern); + if (empty($pattern)) { + continue; + } + + $isExclusion = str_starts_with($pattern, '!'); + $matchPattern = $isExclusion ? substr($pattern, 1) : $pattern; + + if (self::globMatch($matchPattern, $file)) { + // This pattern matches - it determines the current state + $shouldInclude = ! $isExclusion; + } + } + + // If no patterns matched and we only have exclusion patterns, include by default + if ($shouldInclude === null) { + // Check if we only have exclusion patterns + $hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!')); + + return ! $hasInclusionPatterns; + } + + return $shouldInclude; + })->values(); + } + + /** + * Check if a path matches a glob pattern + * Supports: *, **, ?, [abc], [!abc] + */ + public static function globMatch(string $pattern, string $path): bool + { + $regex = self::globToRegex($pattern); + + return preg_match($regex, $path) === 1; + } + + /** + * Convert a glob pattern to a regular expression + */ + public static function globToRegex(string $pattern): string + { + $regex = ''; + $inGroup = false; + $chars = str_split($pattern); + $len = count($chars); + + for ($i = 0; $i < $len; $i++) { + $c = $chars[$i]; + + switch ($c) { + case '*': + // Check for ** + if ($i + 1 < $len && $chars[$i + 1] === '*') { + // ** matches any number of directories + $regex .= '.*'; + $i++; // Skip next * + // Skip optional / + if ($i + 1 < $len && $chars[$i + 1] === '/') { + $i++; + } + } else { + // * matches anything except / + $regex .= '[^/]*'; + } + break; + + case '?': + // ? matches any single character except / + $regex .= '[^/]'; + break; + + case '[': + // Character class + $inGroup = true; + $regex .= '['; + // Check for negation + if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) { + $regex .= '^'; + $i++; + } + break; + + case ']': + if ($inGroup) { + $inGroup = false; + $regex .= ']'; + } else { + $regex .= preg_quote($c, '#'); + } + break; + + case '.': + case '(': + case ')': + case '+': + case '{': + case '}': + case '$': + case '^': + case '|': + case '\\': + // Escape regex special characters + $regex .= '\\'.$c; + break; + + default: + $regex .= $c; + break; + } + } + + // Wrap in delimiters and anchors + return '#^'.$regex.'$#'; + } + + public function normalizeWatchPaths(): void + { + if (is_null($this->watch_paths)) { + return; + } + + $normalized = $this->parseWatchPaths($this->watch_paths); + if ($normalized !== $this->watch_paths) { + $this->watch_paths = $normalized; + $this->save(); + } + } + public function isWatchPathsTriggered(Collection $modified_files): bool { if (is_null($this->watch_paths)) { return false; } + + $this->normalizeWatchPaths(); + $watch_paths = collect(explode("\n", $this->watch_paths)); - $matches = $modified_files->filter(function ($file) use ($watch_paths) { - return $watch_paths->contains(function ($glob) use ($file) { - return fnmatch($glob, $file); - }); - }); + + if ($watch_paths->isEmpty()) { + return false; + } + $matches = $this->matchWatchPaths($modified_files, $watch_paths); return $matches->count() > 0; } @@ -1588,34 +1848,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false } } - public function generate_preview_fqdn(int $pull_request_id) - { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->id, $pull_request_id); - if (is_null(data_get($preview, 'fqdn')) && $this->fqdn) { - if (str($this->fqdn)->contains(',')) { - $url = Url::fromString(str($this->fqdn)->explode(',')[0]); - $preview_fqdn = getFqdnWithoutPort(str($this->fqdn)->explode(',')[0]); - } else { - $url = Url::fromString($this->fqdn); - if (data_get($preview, 'fqdn')) { - $preview_fqdn = getFqdnWithoutPort(data_get($preview, 'fqdn')); - } - } - $template = $this->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - } - - return $preview; - } - public static function getDomainsByUuid(string $uuid): array { $application = self::where('uuid', $uuid)->first(); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2a9bea67a..4e8eee10f 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model { protected $guarded = []; - public function application(): Attribute + public function application() { - return Attribute::make( - get: fn () => Application::find($this->application_id), - ); + return $this->belongsTo(Application::class); } public function server(): Attribute @@ -85,6 +83,47 @@ public function commitMessage() return str($this->commit_message)->value(); } + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $app = $this->application; + if (! $app) { + return $text; + } + + $lockedVars = collect([]); + + if ($app->environment_variables) { + $lockedVars = $lockedVars->merge( + $app->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { + $lockedVars = $lockedVars->merge( + $app->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) { if ($type === 'error') { @@ -96,7 +135,7 @@ public function addLogEntry(string $message, string $type = 'stdout', bool $hidd } $newLogEntry = [ 'command' => null, - 'output' => remove_iip($message), + 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index bf2bf05bf..721b22216 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,19 +2,25 @@ namespace App\Models; +use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; class ApplicationPreview extends BaseModel { + use SoftDeletes; + protected $guarded = []; protected static function booted() { - static::deleting(function ($preview) { + static::forceDeleting(function ($preview) { + $server = $preview->application->destination->server; + $application = $preview->application; + if (data_get($preview, 'application.build_pack') === 'dockercompose') { - $server = $preview->application->destination->server; - $composeFile = $preview->application->parse(pull_request_id: $preview->pull_request_id); + // Docker Compose volume and network cleanup + $composeFile = $application->parse(pull_request_id: $preview->pull_request_id); $volumes = data_get($composeFile, 'volumes'); $networks = data_get($composeFile, 'networks'); $networkKeys = collect($networks)->keys(); @@ -26,7 +32,18 @@ protected static function booted() instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false); instant_remote_process(["docker network rm $key"], $server, false); }); + } else { + // Regular application volume cleanup + $persistentStorages = $preview->persistentStorages()->get() ?? collect(); + if ($persistentStorages->count() > 0) { + foreach ($persistentStorages as $storage) { + instant_remote_process(["docker volume rm -f $storage->name"], $server, false); + } + } } + + // Clean up persistent storage records + $preview->persistentStorages()->delete(); }); static::saving(function ($preview) { if ($preview->isDirty('status')) { @@ -50,12 +67,23 @@ public function application() return $this->belongsTo(Application::class); } - public function generate_preview_fqdn_compose() + public function persistentStorages() { - $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect(); - foreach ($domains as $service_name => $domain) { - $domain = data_get($domain, 'domain'); - $url = Url::fromString($domain); + return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource'); + } + + public function generate_preview_fqdn() + { + if ($this->application->fqdn) { + if (str($this->application->fqdn)->contains(',')) { + $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); + $preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]); + } else { + $url = Url::fromString($this->application->fqdn); + if ($this->fqdn) { + $preview_fqdn = getFqdnWithoutPort($this->fqdn); + } + } $template = $this->application->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); @@ -64,12 +92,76 @@ public function generate_preview_fqdn_compose() $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); $preview_fqdn = "$schema://$preview_fqdn"; - $docker_compose_domains = data_get($this, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$service_name]['domain'] = $preview_fqdn; - $docker_compose_domains = json_encode($docker_compose_domains); - $this->docker_compose_domains = $docker_compose_domains; + $this->fqdn = $preview_fqdn; $this->save(); } + + return $this; + } + + public function generate_preview_fqdn_compose() + { + $services = collect(json_decode($this->application->docker_compose_domains)) ?? collect(); + $docker_compose_domains = data_get($this, 'docker_compose_domains'); + $docker_compose_domains = json_decode($docker_compose_domains, true) ?? []; + + // Get all services from the parsed compose file to ensure all services have entries + $parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id); + if (isset($parsedServices['services'])) { + foreach ($parsedServices['services'] as $serviceName => $service) { + if (! isDatabaseImage(data_get($service, 'image'))) { + // Remove PR suffix from service name to get original service name + $originalServiceName = str($serviceName)->replaceLast('-pr-'.$this->pull_request_id, '')->toString(); + + // Ensure all services have an entry, even if empty + if (! $services->has($originalServiceName)) { + $services->put($originalServiceName, ['domain' => '']); + } + } + } + } + + foreach ($services as $service_name => $service_config) { + $domain_string = data_get($service_config, 'domain'); + + // If domain string is empty or null, don't auto-generate domain + // Only generate domains when main app already has domains set + if (empty($domain_string)) { + // Ensure service has an empty domain entry for form binding + $docker_compose_domains[$service_name]['domain'] = ''; + + continue; + } + + $service_domains = str($domain_string)->explode(',')->map(fn ($d) => trim($d)); + + $preview_domains = []; + foreach ($service_domains as $domain) { + if (empty($domain)) { + continue; + } + + $url = Url::fromString($domain); + $template = $this->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $random = new Cuid2; + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); + $preview_fqdn = "$schema://$preview_fqdn"; + $preview_domains[] = $preview_fqdn; + } + + if (! empty($preview_domains)) { + $docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains); + } else { + // Ensure service has an empty domain entry for form binding + $docker_compose_domains[$service_name]['domain'] = ''; + } + } + + $this->docker_compose_domains = json_encode($docker_compose_domains); + $this->save(); } } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index c7624fdaa..4b03c69e1 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -13,8 +13,10 @@ class ApplicationSetting extends Model 'is_force_https_enabled' => 'boolean', 'is_debug_enabled' => 'boolean', 'is_preview_deployments_enabled' => 'boolean', + 'is_pr_deployments_public_enabled' => 'boolean', 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', + 'is_git_shallow_clone_enabled' => 'boolean', ]; protected $guarded = []; diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php new file mode 100644 index 000000000..2c78cc582 --- /dev/null +++ b/app/Models/CloudInitScript.php @@ -0,0 +1,33 @@ + 'encrypted', + ]; + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } +} diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php new file mode 100644 index 000000000..607040269 --- /dev/null +++ b/app/Models/CloudProviderToken.php @@ -0,0 +1,41 @@ + 'encrypted', + ]; + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function servers() + { + return $this->hasMany(Server::class); + } + + public function hasServers(): bool + { + return $this->servers()->exists(); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } + + public function scopeForProvider($query, string $provider) + { + return $query->where('provider', $provider); + } +} diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 1ba16ccd8..34adfc997 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -28,6 +28,7 @@ class DiscordNotificationSettings extends Model 'server_disk_usage_discord_notifications', 'server_reachable_discord_notifications', 'server_unreachable_discord_notifications', + 'server_patch_discord_notifications', 'discord_ping_enabled', ]; @@ -46,6 +47,7 @@ class DiscordNotificationSettings extends Model 'server_disk_usage_discord_notifications' => 'boolean', 'server_reachable_discord_notifications' => 'boolean', 'server_unreachable_discord_notifications' => 'boolean', + 'server_patch_discord_notifications' => 'boolean', 'discord_ping_enabled' => 'boolean', ]; diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 445987619..39617b4cf 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -35,6 +35,7 @@ class EmailNotificationSettings extends Model 'scheduled_task_success_email_notifications', 'scheduled_task_failure_email_notifications', 'server_disk_usage_email_notifications', + 'server_patch_email_notifications', ]; protected $casts = [ @@ -61,6 +62,7 @@ class EmailNotificationSettings extends Model 'scheduled_task_success_email_notifications' => 'boolean', 'scheduled_task_failure_email_notifications' => 'boolean', 'server_disk_usage_email_notifications' => 'boolean', + 'server_patch_email_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/Environment.php b/app/Models/Environment.php index b8f1090d8..c2ad9d2cb 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,7 +2,8 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; #[OA\Schema( @@ -19,6 +20,9 @@ )] class Environment extends BaseModel { + use ClearsGlobalSearchCache; + use HasSafeStringAttribute; + protected $guarded = []; protected static function booted() @@ -31,6 +35,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && @@ -119,10 +128,8 @@ public function services() return $this->hasMany(Service::class); } - protected function name(): Attribute + protected function customizeName($value) { - return Attribute::make( - set: fn (string $value) => str($value)->lower()->trim()->replace('/', '-')->toString(), - ); + return str($value)->lower()->trim()->replace('/', '-')->toString(); } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 5f686de60..80399a16b 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -14,10 +14,11 @@ 'uuid' => ['type' => 'string'], 'resourceable_type' => ['type' => 'string'], 'resourceable_id' => ['type' => 'integer'], - 'is_build_time' => ['type' => 'boolean'], 'is_literal' => ['type' => 'boolean'], 'is_multiline' => ['type' => 'boolean'], 'is_preview' => ['type' => 'boolean'], + 'is_runtime' => ['type' => 'boolean'], + 'is_buildtime' => ['type' => 'boolean'], 'is_shared' => ['type' => 'boolean'], 'is_shown_once' => ['type' => 'boolean'], 'key' => ['type' => 'string'], @@ -35,15 +36,16 @@ class EnvironmentVariable extends BaseModel protected $casts = [ 'key' => 'string', 'value' => 'encrypted', - 'is_build_time' => 'boolean', 'is_multiline' => 'boolean', 'is_preview' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', 'version' => 'string', 'resourceable_type' => 'string', 'resourceable_id' => 'integer', ]; - protected $appends = ['real_value', 'is_shared', 'is_really_required']; + protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; protected static function booted() { @@ -57,12 +59,12 @@ protected static function booted() if (! $found) { $application = Application::find($environment_variable->resourceable_id); - if ($application && $application->build_pack !== 'dockerfile') { + if ($application) { ModelsEnvironmentVariable::create([ 'key' => $environment_variable->key, 'value' => $environment_variable->value, - 'is_build_time' => $environment_variable->is_build_time, 'is_multiline' => $environment_variable->is_multiline ?? false, + 'is_literal' => $environment_variable->is_literal ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, @@ -118,7 +120,14 @@ public function realValue(): Attribute return null; } - return $this->get_real_environment_variables($this->value, $resource); + $real_value = $this->get_real_environment_variables($this->value, $resource); + if ($this->is_literal || $this->is_multiline) { + $real_value = '\''.$real_value.'\''; + } else { + $real_value = escapeEnvVariables($real_value); + } + + return $real_value; } ); } @@ -130,6 +139,32 @@ protected function isReallyRequired(): Attribute ); } + protected function isNixpacks(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('NIXPACKS_')) { + return true; + } + + return false; + } + ); + } + + protected function isCoolify(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('SERVICE_')) { + return true; + } + + return false; + } + ); + } + protected function isShared(): Attribute { return Attribute::make( diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index ac95bb8a9..cd1c05de4 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -35,13 +35,18 @@ class InstanceSettings extends Model protected static function booted(): void { static::updated(function ($settings) { - if ($settings->isDirty('helper_version')) { + if ($settings->wasChanged('helper_version')) { Server::chunkById(100, function ($servers) { foreach ($servers as $server) { PullHelperImageJob::dispatch($server); } }); } + + // Clear trusted hosts cache when FQDN changes + if ($settings->wasChanged('fqdn')) { + \Cache::forget('instance_settings_fqdn_host'); + } }); } diff --git a/app/Models/Kubernetes.php b/app/Models/Kubernetes.php deleted file mode 100644 index 174cb5bc8..000000000 --- a/app/Models/Kubernetes.php +++ /dev/null @@ -1,5 +0,0 @@ -is_directory) { $commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true"); + $commands->push("mkdir -p $workdir > /dev/null 2>&1 || true"); $commands->push("cd $workdir"); } if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { @@ -191,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path) { return $query->get()->where('plain_mount_path', $path); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Only check for services + $service = $this->service; + if (! $service || ! method_exists($service, 'service')) { + return false; + } + + $actualService = $service->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $service->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our fs_path and mount_path + if (count($parts) >= 2) { + $hostPath = $parts[0]; + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on fs_path and mount_path + if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only volume'); + + return false; + } + } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index b5dfd9663..e7862478b 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends Model { @@ -24,11 +25,9 @@ public function database() return $this->morphTo('resource'); } - protected function name(): Attribute + protected function customizeName($value) { - return Attribute::make( - set: fn (string $value) => str($value)->trim()->value, - ); + return str($value)->trim()->value; } protected function mountPath(): Attribute @@ -50,4 +49,69 @@ protected function hostPath(): Attribute } ); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Get the resource (can be application, service, or database) + $resource = $this->resource; + if (! $resource) { + return false; + } + + // Only check for services + if (! method_exists($resource, 'service')) { + return false; + } + + $actualService = $resource->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $resource->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our mount_path + if (count($parts) >= 2) { + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only persistent volume'); + + return false; + } + } } diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index bfd332c87..08e08d85b 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -27,6 +27,7 @@ public function couldBeEnabled(): bool case 'azure': return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': + case 'clerk': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index dbed7b439..08f3f1ebd 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; @@ -27,7 +29,7 @@ )] class PrivateKey extends BaseModel { - use WithRateLimiting; + use HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', @@ -98,11 +100,18 @@ public static function validatePrivateKey($privateKey) public static function createAndStore(array $data) { - $privateKey = new self($data); - $privateKey->save(); - $privateKey->storeInFileSystem(); + return DB::transaction(function () use ($data) { + $privateKey = new self($data); + $privateKey->save(); - return $privateKey; + try { + $privateKey->storeInFileSystem(); + } catch (\Exception $e) { + throw new \Exception('Failed to store SSH key: '.$e->getMessage()); + } + + return $privateKey; + }); } public static function generateNewKeyPair($type = 'rsa') @@ -150,15 +159,64 @@ public static function validateAndExtractPublicKey($privateKey) public function storeInFileSystem() { $filename = "ssh_key@{$this->uuid}"; - Storage::disk('ssh-keys')->put($filename, $this->private_key); + $disk = Storage::disk('ssh-keys'); - return "/var/www/html/storage/app/ssh/keys/{$filename}"; + // Ensure the storage directory exists and is writable + $this->ensureStorageDirectoryExists(); + + // Attempt to store the private key + $success = $disk->put($filename, $this->private_key); + + if (! $success) { + throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}"); + } + + // Verify the file was actually created and has content + if (! $disk->exists($filename)) { + throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}"); + } + + $storedContent = $disk->get($filename); + if (empty($storedContent) || $storedContent !== $this->private_key) { + $disk->delete($filename); // Clean up the bad file + throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}"); + } + + return $this->getKeyLocation(); } public static function deleteFromStorage(self $privateKey) { $filename = "ssh_key@{$privateKey->uuid}"; - Storage::disk('ssh-keys')->delete($filename); + $disk = Storage::disk('ssh-keys'); + + if ($disk->exists($filename)) { + $disk->delete($filename); + } + } + + protected function ensureStorageDirectoryExists() + { + $disk = Storage::disk('ssh-keys'); + $directoryPath = ''; + + if (! $disk->exists($directoryPath)) { + $success = $disk->makeDirectory($directoryPath); + if (! $success) { + throw new \Exception('Failed to create SSH keys storage directory'); + } + } + + // Check if directory is writable by attempting a test file + $testFilename = '.test_write_'.uniqid(); + $testSuccess = $disk->put($testFilename, 'test'); + + if (! $testSuccess) { + throw new \Exception('SSH keys storage directory is not writable'); + } + + // Clean up test file + $disk->delete($testFilename); } public function getKeyLocation() @@ -168,10 +226,17 @@ public function getKeyLocation() public function updatePrivateKey(array $data) { - $this->update($data); - $this->storeInFileSystem(); + return DB::transaction(function () use ($data) { + $this->update($data); - return $this; + try { + $this->storeInFileSystem(); + } catch (\Exception $e) { + throw new \Exception('Failed to update SSH key: '.$e->getMessage()); + } + + return $this; + }); } public function servers() @@ -224,6 +289,17 @@ public static function generateFingerprint($privateKey) } } + public static function generateMd5Fingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->getFingerprint('md5'); + } catch (\Throwable $e) { + return null; + } + } + public static function fingerprintExists($fingerprint, $excludeId = null) { $query = self::query() diff --git a/app/Models/Project.php b/app/Models/Project.php index 2e4d45859..a9bf76803 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -23,6 +25,9 @@ )] class Project extends BaseModel { + use ClearsGlobalSearchCache; + use HasSafeStringAttribute; + protected $guarded = []; public static function ownedByCurrentTeam() diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index e3191dcc3..a75fd71d7 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -29,6 +29,7 @@ class PushoverNotificationSettings extends Model 'server_disk_usage_pushover_notifications', 'server_reachable_pushover_notifications', 'server_unreachable_pushover_notifications', + 'server_patch_pushover_notifications', ]; protected $casts = [ @@ -47,6 +48,7 @@ class PushoverNotificationSettings extends Model 'server_disk_usage_pushover_notifications' => 'boolean', 'server_reachable_pushover_notifications' => 'boolean', 'server_unreachable_pushover_notifications' => 'boolean', + 'server_patch_pushover_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e9d674650..de27bbca6 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; class S3Storage extends BaseModel { - use HasFactory; + use HasFactory, HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 473fc7b4b..3ade21df8 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -10,6 +10,21 @@ class ScheduledDatabaseBackup extends BaseModel { protected $guarded = []; + public static function ownedByCurrentTeam() + { + return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('created_at', 'desc'); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('created_at', 'desc'); + } + + public function team() + { + return $this->belongsTo(Team::class); + } + public function database(): MorphTo { return $this->morphTo(); @@ -36,6 +51,18 @@ public function get_last_days_backup_status($days = 7) return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); } + public function executionsPaginated(int $skip = 0, int $take = 10) + { + $executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->orderBy('created_at', 'desc'); + $count = $executions->count(); + $executions = $executions->skip($skip)->take($take)->get(); + + return [ + 'count' => $count, + 'executions' => $executions, + ]; + } + public function server() { if ($this->database) { diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index b06dd5b45..c0298ecc8 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel { protected $guarded = []; + protected function casts(): array + { + return [ + 's3_uploaded' => 'boolean', + 'local_storage_deleted' => 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + public function scheduledDatabaseBackup(): BelongsTo { return $this->belongsTo(ScheduledDatabaseBackup::class); diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 264a04d1f..06903ffb6 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; class ScheduledTask extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; public function service() diff --git a/app/Models/Server.php b/app/Models/Server.php index fb9be5de7..e39526949 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -13,6 +13,8 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -54,7 +56,7 @@ class Server extends BaseModel { - use HasFactory, SchemalessAttributesTrait, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; @@ -69,6 +71,11 @@ protected static function booted() } if ($server->ip) { $payload['ip'] = str($server->ip)->trim(); + + // Update ip_previous when ip is being changed + if ($server->isDirty('ip') && $server->getOriginal('ip')) { + $payload['ip_previous'] = $server->getOriginal('ip'); + } } $server->forceFill($payload); }); @@ -129,6 +136,7 @@ protected static function booted() $destination->delete(); }); $server->settings()->delete(); + $server->sslCertificates()->delete(); }); } @@ -154,11 +162,17 @@ protected static function booted() 'user', 'description', 'private_key_id', + 'cloud_provider_token_id', 'team_id', + 'hetzner_server_id', + 'hetzner_server_status', + 'is_validating', ]; protected $guarded = []; + use HasSafeStringAttribute; + public function type() { return 'server'; @@ -880,9 +894,19 @@ public function privateKey() return $this->belongsTo(PrivateKey::class); } + public function cloudProviderToken() + { + return $this->belongsTo(CloudProviderToken::class); + } + + public function sslCertificates() + { + return $this->hasMany(SslCertificate::class); + } + public function muxFilename() { - return $this->uuid; + return 'mux_'.$this->uuid; } public function team() @@ -952,6 +976,11 @@ public function validateOS(): bool|Stringable } } + public function isTerminalEnabled() + { + return $this->settings->is_terminal_enabled ?? false; + } + public function isSwarm() { return data_get($this, 'settings.is_swarm_manager') || data_get($this, 'settings.is_swarm_worker'); @@ -1246,13 +1275,13 @@ public function isIpv6(): bool return str($this->ip)->contains(':'); } - public function restartSentinel(bool $async = true) + public function restartSentinel(?string $customImage = null, bool $async = true) { try { if ($async) { - StartSentinel::dispatch($this, true); + StartSentinel::dispatch($this, true, null, $customImage); } else { - StartSentinel::run($this, true); + StartSentinel::run($this, true, null, $customImage); } } catch (\Throwable $e) { return handleError($e); @@ -1313,7 +1342,7 @@ public function generateCaCertificate() isCaCertificate: true, validityDays: 10 * 365 ); - $caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first(); + $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); ray('CA certificate generated', $caCertificate); if ($caCertificate) { $certificateContent = $caCertificate->ssl_certificate; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f4b776cca..6da4dd4c6 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -28,6 +28,7 @@ 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], 'is_swarm_worker' => ['type' => 'boolean'], + 'is_terminal_enabled' => ['type' => 'boolean'], 'is_usable' => ['type' => 'boolean'], 'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'], @@ -59,6 +60,7 @@ class ServerSetting extends Model 'sentinel_token' => 'encrypted', 'is_reachable' => 'boolean', 'is_usable' => 'boolean', + 'is_terminal_enabled' => 'boolean', ]; protected static function booted() @@ -77,11 +79,11 @@ protected static function booted() }); static::updated(function ($settings) { if ( - $settings->isDirty('sentinel_token') || - $settings->isDirty('sentinel_custom_url') || - $settings->isDirty('sentinel_metrics_refresh_rate_seconds') || - $settings->isDirty('sentinel_metrics_history_days') || - $settings->isDirty('sentinel_push_interval_seconds') + $settings->wasChanged('sentinel_token') || + $settings->wasChanged('sentinel_custom_url') || + $settings->wasChanged('sentinel_metrics_refresh_rate_seconds') || + $settings->wasChanged('sentinel_metrics_history_days') || + $settings->wasChanged('sentinel_push_interval_seconds') ) { $settings->server->restartSentinel(); } diff --git a/app/Models/Service.php b/app/Models/Service.php index 13e3a89c0..c4b8623e0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,6 +2,9 @@ namespace App\Models; +use App\Enums\ProcessStatus; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -9,6 +12,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; +use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -38,9 +42,9 @@ )] class Service extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; - private static $parserVersion = '4'; + private static $parserVersion = '5'; protected $guarded = []; @@ -116,6 +120,18 @@ public function isExited() return (bool) str($this->status)->contains('exited'); } + public function isStarting(): bool + { + try { + $activity = Activity::where('properties->type_uuid', $this->uuid)->latest()->first(); + $status = data_get($activity, 'properties.status'); + + return $status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value; + } catch (\Throwable) { + return false; + } + } + public function type() { return 'service'; @@ -141,31 +157,6 @@ public static function ownedByCurrentTeam() return Service::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } - public function getContainersToStop(): array - { - $containersToStop = []; - $applications = $this->applications()->get(); - foreach ($applications as $application) { - $containersToStop[] = "{$application->name}-{$this->uuid}"; - } - $dbs = $this->databases()->get(); - foreach ($dbs as $db) { - $containersToStop[] = "{$db->name}-{$this->uuid}"; - } - - return $containersToStop; - } - - public function stopContainers(array $containerNames, $server, int $timeout = 30) - { - foreach ($containerNames as $containerName) { - instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", - "docker rm -f $containerName", - ], server: $server, throwError: false); - } - } - public function deleteConfigurations() { $server = data_get($this, 'destination.server'); @@ -184,6 +175,10 @@ public function deleteConnectedNetworks() public function getStatusAttribute() { + if ($this->isStarting()) { + return 'starting:unhealthy'; + } + $applications = $this->applications; $databases = $this->databases; @@ -262,6 +257,19 @@ public function extraFields() continue; } switch ($image) { + case $image->contains('drizzle-team/gateway'): + $data = collect([]); + $masterpass = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_DRIZZLE')->first(); + $data = $data->merge([ + 'Master Password' => [ + 'key' => data_get($masterpass, 'key'), + 'value' => data_get($masterpass, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + $fields->put('Drizzle', $data->toArray()); + break; case $image->contains('castopod'): $data = collect([]); $disable_https = $this->environment_variables()->where('key', 'CP_DISABLE_HTTPS')->first(); @@ -539,6 +547,21 @@ public function extraFields() } $fields->put('Grafana', $data->toArray()); break; + case $image->contains('elasticsearch'): + $data = collect([]); + $elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first(); + if ($elastic_password) { + $data = $data->merge([ + 'Password (default user: elastic)' => [ + 'key' => data_get($elastic_password, 'key'), + 'value' => data_get($elastic_password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Elasticsearch', $data->toArray()); + break; case $image->contains('directus'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); @@ -1106,7 +1129,6 @@ public function saveExtraFields($fields) $this->environment_variables()->create([ 'key' => $key, 'value' => $value, - 'is_build_time' => false, 'resourceable_id' => $this->id, 'resourceable_type' => $this->getMorphClass(), 'is_preview' => false, @@ -1223,14 +1245,14 @@ public function scheduled_tasks(): HasMany public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); - } - - public function environment_variables_preview() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); + ->orderByRaw(" + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function workdir() @@ -1256,6 +1278,21 @@ public function saveComposeConfigs() $commands[] = "cd $workdir"; $commands[] = 'rm -f .env || true'; + $envs = collect([]); + + // Generate SERVICE_NAME_* environment variables from docker-compose services + if ($this->docker_compose) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); + } + } catch (\Exception $e) { + ray($e->getMessage()); + } + } + $envs_from_coolify = $this->environment_variables()->get(); $sorted = $envs_from_coolify->sortBy(function ($env) { if (str($env->key)->startsWith('SERVICE_')) { @@ -1268,32 +1305,22 @@ public function saveComposeConfigs() return 3; }); foreach ($sorted as $env) { - if (version_compare($env->version, '4.0.0-beta.347', '<=')) { - $commands[] = "echo '{$env->key}={$env->real_value}' >> .env"; - } else { - $real_value = $env->real_value; - if ($env->version === '4.0.0-beta.239') { - $real_value = $env->real_value; - } else { - if ($env->is_literal || $env->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($env->real_value); - } - } - $commands[] = "echo \"{$env->key}={$real_value}\" >> .env"; - } + $envs->push("{$env->key}={$env->real_value}"); } - if ($sorted->count() === 0) { + if ($envs->count() === 0) { $commands[] = 'touch .env'; + } else { + $envs_base64 = base64_encode($envs->implode("\n")); + $commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null"; } + instant_remote_process($commands, $this->server); } public function parse(bool $isNew = false): Collection { if ((int) $this->compose_parsing_version >= 3) { - return newParser($this); + return serviceParser($this); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile($this, $isNew); } else { diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index aab8b8735..7956f006a 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -12,4 +12,19 @@ class SharedEnvironmentVariable extends Model 'key' => 'string', 'value' => 'encrypted', ]; + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function project() + { + return $this->belongsTo(Project::class); + } + + public function environment() + { + return $this->belongsTo(Environment::class); + } } diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 48153f2ea..2b52bfd5b 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -28,6 +28,7 @@ class SlackNotificationSettings extends Model 'server_disk_usage_slack_notifications', 'server_reachable_slack_notifications', 'server_unreachable_slack_notifications', + 'server_patch_slack_notifications', ]; protected $casts = [ @@ -45,6 +46,7 @@ class SlackNotificationSettings extends Model 'server_disk_usage_slack_notifications' => 'boolean', 'server_reachable_slack_notifications' => 'boolean', 'server_unreachable_slack_notifications' => 'boolean', + 'server_patch_slack_notifications' => 'boolean', ]; public function team() diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index fcd81cdc9..146ee0a2d 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -27,7 +29,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -43,6 +44,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -266,7 +272,14 @@ public function destination() public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 9db6a2d29..aeb99d34a 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -2,8 +2,12 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; + class StandaloneDocker extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; protected static function boot() diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index fdf69b834..90e7304f1 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -27,7 +29,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -43,6 +44,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -341,6 +347,13 @@ public function isBackupSolutionAvailable() public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index d52023920..ad0cabf7e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -27,7 +29,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -43,6 +44,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -341,6 +347,13 @@ public function isBackupSolutionAvailable() public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 5a8869b41..3d9e38147 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -9,7 +11,7 @@ class StandaloneMariadb extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +30,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +45,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -262,7 +268,14 @@ public function destination(): MorphTo public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 88833eebe..7cccd332a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -23,7 +25,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); LocalPersistentVolume::create([ 'name' => 'mongodb-db-'.$database->uuid, @@ -31,7 +32,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -47,6 +47,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -364,6 +369,13 @@ public function isBackupSolutionAvailable() public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index dedc35f91..80269972f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +30,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +45,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -345,6 +351,13 @@ public function isBackupSolutionAvailable() public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 689134a32..acde7a20c 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +30,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +45,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; @@ -296,7 +302,14 @@ public function scheduledBackups() public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function isBackupSolutionAvailable() @@ -320,7 +333,10 @@ public function getCpuMetrics(int $mins = 5) } $metrics = json_decode($metrics, true); $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; + return [ + (int) $metric['time'], + (float) ($metric['percent'] ?? 0.0), + ]; }); return $parsedCollection->toArray(); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 7f6f2ad72..001ebe36a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -23,7 +25,6 @@ protected static function booted() 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -45,6 +46,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -388,6 +394,13 @@ public function redisUsername(): Attribute public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index a64c994a3..3594d1072 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,18 +2,17 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; +use App\Traits\HasSafeStringAttribute; class Tag extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; - public function name(): Attribute + protected function customizeName($value) { - return Attribute::make( - get: fn ($value) => strtolower($value), - set: fn ($value) => strtolower($value) - ); + return strtolower($value); } public static function ownedByCurrentTeam() diff --git a/app/Models/Team.php b/app/Models/Team.php index 42b88f9e7..6c30389ee 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,7 +8,9 @@ use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; use App\Traits\HasNotificationSettings; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; use OpenApi\Attributes as OA; @@ -36,7 +38,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasNotificationSettings, Notifiable; + use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $guarded = []; @@ -52,6 +54,7 @@ protected static function booted() $team->slackNotificationSettings()->create(); $team->telegramNotificationSettings()->create(); $team->pushoverNotificationSettings()->create(); + $team->webhookNotificationSettings()->create(); }); static::saving(function ($team) { @@ -192,6 +195,7 @@ public function isAnyNotificationEnabled() public function subscriptionEnded() { $this->subscription->update([ + 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, 'stripe_trial_already_ended' => false, @@ -255,6 +259,11 @@ public function privateKeys() return $this->hasMany(PrivateKey::class); } + public function cloudProviderTokens() + { + return $this->hasMany(CloudProviderToken::class); + } + public function sources() { $sources = collect([]); @@ -304,4 +313,9 @@ public function pushoverNotificationSettings() { return $this->hasOne(PushoverNotificationSettings::class); } + + public function webhookNotificationSettings() + { + return $this->hasOne(WebhookNotificationSettings::class); + } } diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index bc1a90d58..c322982ed 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -15,6 +15,14 @@ class TeamInvitation extends Model 'via', ]; + /** + * Set the email attribute to lowercase. + */ + public function setEmailAttribute(string $value): void + { + $this->attributes['email'] = strtolower($value); + } + public function team() { return $this->belongsTo(Team::class); @@ -33,6 +41,10 @@ public function isValid() return true; } else { $this->delete(); + $user = User::whereEmail($this->email)->first(); + if (filled($user)) { + $user->deleteIfNotVerifiedAndForcePasswordReset(); + } return false; } diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 78bd841bd..94315ee30 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -29,6 +29,7 @@ class TelegramNotificationSettings extends Model 'server_disk_usage_telegram_notifications', 'server_reachable_telegram_notifications', 'server_unreachable_telegram_notifications', + 'server_patch_telegram_notifications', 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', @@ -41,6 +42,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_server_disk_usage_thread_id', 'telegram_notifications_server_reachable_thread_id', 'telegram_notifications_server_unreachable_thread_id', + 'telegram_notifications_server_patch_thread_id', ]; protected $casts = [ @@ -59,6 +61,7 @@ class TelegramNotificationSettings extends Model 'server_disk_usage_telegram_notifications' => 'boolean', 'server_reachable_telegram_notifications' => 'boolean', 'server_unreachable_telegram_notifications' => 'boolean', + 'server_patch_telegram_notifications' => 'boolean', 'telegram_notifications_deployment_success_thread_id' => 'encrypted', 'telegram_notifications_deployment_failure_thread_id' => 'encrypted', @@ -71,6 +74,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_server_disk_usage_thread_id' => 'encrypted', 'telegram_notifications_server_reachable_thread_id' => 'encrypted', 'telegram_notifications_server_unreachable_thread_id' => 'encrypted', + 'telegram_notifications_server_patch_thread_id' => 'encrypted', ]; public function team() diff --git a/app/Models/User.php b/app/Models/User.php index f9515ad09..9ab9fefe9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -53,8 +53,25 @@ class User extends Authenticatable implements SendsEmail 'email_verified_at' => 'datetime', 'force_password_reset' => 'boolean', 'show_boarding' => 'boolean', + 'email_change_code_expires_at' => 'datetime', ]; + /** + * Set the email attribute to lowercase. + */ + public function setEmailAttribute($value) + { + $this->attributes['email'] = strtolower($value); + } + + /** + * Set the pending_email attribute to lowercase. + */ + public function setPendingEmailAttribute($value) + { + $this->attributes['pending_email'] = $value ? strtolower($value) : null; + } + protected static function boot() { parent::boot(); @@ -72,6 +89,93 @@ protected static function boot() $new_team = Team::create($team); $user->teams()->attach($new_team, ['role' => 'owner']); }); + + static::deleting(function (User $user) { + \DB::transaction(function () use ($user) { + $teams = $user->teams; + foreach ($teams as $team) { + $user_alone_in_team = $team->members->count() === 1; + + // Prevent deletion if user is alone in root team + if ($team->id === 0 && $user_alone_in_team) { + throw new \Exception('User is alone in the root team, cannot delete'); + } + + if ($user_alone_in_team) { + static::finalizeTeamDeletion($user, $team); + // Delete any pending team invitations for this user + TeamInvitation::whereEmail($user->email)->delete(); + + continue; + } + + // Load the user's role for this team + $userRole = $team->members->where('id', $user->id)->first()?->pivot?->role; + + if ($userRole === 'owner') { + $found_other_owner_or_admin = $team->members->filter(function ($member) use ($user) { + return ($member->pivot->role === 'owner' || $member->pivot->role === 'admin') && $member->id !== $user->id; + })->first(); + + if ($found_other_owner_or_admin) { + $team->members()->detach($user->id); + + continue; + } else { + $found_other_member_who_is_not_owner = $team->members->filter(function ($member) { + return $member->pivot->role === 'member'; + })->first(); + + if ($found_other_member_who_is_not_owner) { + $found_other_member_who_is_not_owner->pivot->role = 'owner'; + $found_other_member_who_is_not_owner->pivot->save(); + $team->members()->detach($user->id); + } else { + static::finalizeTeamDeletion($user, $team); + } + + continue; + } + } else { + $team->members()->detach($user->id); + } + } + }); + }); + } + + /** + * Finalize team deletion by cleaning up all associated resources + */ + private static function finalizeTeamDeletion(User $user, Team $team) + { + $servers = $team->servers; + foreach ($servers as $server) { + $resources = $server->definedResources(); + foreach ($resources as $resource) { + $resource->forceDelete(); + } + $server->forceDelete(); + } + + $projects = $team->projects; + foreach ($projects as $project) { + $project->forceDelete(); + } + + $team->members()->detach($user->id); + $team->delete(); + } + + /** + * Delete the user if they are not verified and have a force password reset. + * This is used to clean up users that have been invited, did not accept the invitation (and did not verify their email and have a force password reset). + */ + public function deleteIfNotVerifiedAndForcePasswordReset() + { + if ($this->hasVerifiedEmail() === false && $this->force_password_reset === true) { + $this->delete(); + } } public function recreate_personal_team() @@ -116,6 +220,16 @@ public function teams() return $this->belongsToMany(Team::class)->withPivot('role'); } + public function changelogReads() + { + return $this->hasMany(UserChangelogRead::class); + } + + public function getUnreadChangelogCount(): int + { + return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this); + } + public function getRecipients(): array { return [$this->email]; @@ -223,4 +337,77 @@ public function role() return data_get($user, 'pivot.role'); } + + public function requestEmailChange(string $newEmail): void + { + // Generate 6-digit code + $code = sprintf('%06d', mt_rand(0, 999999)); + + // Set expiration using config value + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + $expiresAt = Carbon::now()->addMinutes($expiryMinutes); + + $this->update([ + 'pending_email' => $newEmail, + 'email_change_code' => $code, + 'email_change_code_expires_at' => $expiresAt, + ]); + + // Send verification email to new address + $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt)); + } + + public function isEmailChangeCodeValid(string $code): bool + { + return $this->email_change_code === $code + && $this->email_change_code_expires_at + && Carbon::now()->lessThan($this->email_change_code_expires_at); + } + + public function confirmEmailChange(string $code): bool + { + if (! $this->isEmailChangeCodeValid($code)) { + return false; + } + + $oldEmail = $this->email; + $newEmail = $this->pending_email; + + // Update email and clear change request fields + $this->update([ + 'email' => $newEmail, + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + + // For cloud users, dispatch job to update Stripe customer email asynchronously + if (isCloud() && $this->currentTeam()->subscription) { + dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob( + $this->currentTeam(), + $this->id, + $newEmail, + $oldEmail + )); + } + + return true; + } + + public function clearEmailChangeRequest(): void + { + $this->update([ + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + } + + public function hasEmailChangeRequest(): bool + { + return ! is_null($this->pending_email) + && ! is_null($this->email_change_code) + && $this->email_change_code_expires_at + && Carbon::now()->lessThan($this->email_change_code_expires_at); + } } diff --git a/app/Models/UserChangelogRead.php b/app/Models/UserChangelogRead.php new file mode 100644 index 000000000..8c29ece14 --- /dev/null +++ b/app/Models/UserChangelogRead.php @@ -0,0 +1,48 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function markAsRead(int $userId, string $identifier): void + { + self::firstOrCreate([ + 'user_id' => $userId, + 'release_tag' => $identifier, + ], [ + 'read_at' => now(), + ]); + } + + public static function isReadByUser(int $userId, string $identifier): bool + { + return self::where('user_id', $userId) + ->where('release_tag', $identifier) + ->exists(); + } + + public static function getReadIdentifiersForUser(int $userId): array + { + return self::where('user_id', $userId) + ->pluck('release_tag') + ->toArray(); + } +} diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php deleted file mode 100644 index 8e2b62955..000000000 --- a/app/Models/Webhook.php +++ /dev/null @@ -1,15 +0,0 @@ - 'string', - 'payload' => 'encrypted', - ]; -} diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php new file mode 100644 index 000000000..4ca89e0d3 --- /dev/null +++ b/app/Models/WebhookNotificationSettings.php @@ -0,0 +1,64 @@ + 'boolean', + 'webhook_url' => 'encrypted', + + 'deployment_success_webhook_notifications' => 'boolean', + 'deployment_failure_webhook_notifications' => 'boolean', + 'status_change_webhook_notifications' => 'boolean', + 'backup_success_webhook_notifications' => 'boolean', + 'backup_failure_webhook_notifications' => 'boolean', + 'scheduled_task_success_webhook_notifications' => 'boolean', + 'scheduled_task_failure_webhook_notifications' => 'boolean', + 'docker_cleanup_webhook_notifications' => 'boolean', + 'server_disk_usage_webhook_notifications' => 'boolean', + 'server_reachable_webhook_notifications' => 'boolean', + 'server_unreachable_webhook_notifications' => 'boolean', + 'server_patch_webhook_notifications' => 'boolean', + ]; + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function isEnabled() + { + return $this->webhook_enabled; + } +} diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index dec361e78..8fff7f03b 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -185,4 +185,30 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => false, + 'message' => 'Deployment failed', + 'event' => 'deployment_failed', + 'application_name' => $this->application_name, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $this->deployment_uuid, + 'deployment_url' => $this->deployment_url, + 'project' => data_get($this->application, 'environment.project.name'), + 'environment' => $this->environment_name, + ]; + + if ($this->preview) { + $data['pull_request_id'] = $this->preview->pull_request_id; + $data['preview_fqdn'] = $this->preview->fqdn; + } + + if ($this->fqdn) { + $data['fqdn'] = $this->fqdn; + } + + return $data; + } } diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 9b59d9162..415df5831 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -205,4 +205,30 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => true, + 'message' => 'New version successfully deployed', + 'event' => 'deployment_success', + 'application_name' => $this->application_name, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $this->deployment_uuid, + 'deployment_url' => $this->deployment_url, + 'project' => data_get($this->application, 'environment.project.name'), + 'environment' => $this->environment_name, + ]; + + if ($this->preview) { + $data['pull_request_id'] = $this->preview->pull_request_id; + $data['preview_fqdn'] = $this->preview->fqdn; + } + + if ($this->fqdn) { + $data['fqdn'] = $this->fqdn; + } + + return $data; + } } diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index fab5487ef..ef61b7e6a 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -113,4 +113,19 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Application stopped', + 'event' => 'status_changed', + 'application_name' => $this->resource_name, + 'application_uuid' => $this->resource->uuid, + 'url' => $this->resource_url, + 'project' => data_get($this->resource, 'environment.project.name'), + 'environment' => $this->environment_name, + 'fqdn' => $this->fqdn, + ]; + } } diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 8a9a95107..245bd85f0 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -2,6 +2,9 @@ namespace App\Notifications\Channels; +use App\Exceptions\NonReportableException; +use App\Models\Team; +use Exception; use Illuminate\Notifications\Notification; use Resend; @@ -11,60 +14,100 @@ public function __construct() {} public function send(SendsEmail $notifiable, Notification $notification): void { - $useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings; - $isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false); - $customEmails = data_get($notification, 'emails', null); - if ($useInstanceEmailSettings || $isTransactionalEmail) { - $settings = instanceSettings(); - } else { - $settings = $notifiable->emailNotificationSettings; - } - $isResendEnabled = $settings->resend_enabled; - $isSmtpEnabled = $settings->smtp_enabled; - if ($customEmails) { - $recipients = [$customEmails]; - } else { - $recipients = $notifiable->getRecipients(); - } - $mailMessage = $notification->toMail($notifiable); + try { + // Get team and validate membership before proceeding + $team = data_get($notifiable, 'id'); + $members = Team::find($team)->members; - if ($isResendEnabled) { - $resend = Resend::client($settings->resend_api_key); - $from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>"; - $resend->emails->send([ - 'from' => $from, - 'to' => $recipients, - 'subject' => $mailMessage->subject, - 'html' => (string) $mailMessage->render(), - ]); - } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { - 'starttls' => null, - 'tls' => 'tls', - 'none' => null, - default => null, - }; + $useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings; + $isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false); + $customEmails = data_get($notification, 'emails', null); - $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( - $settings->smtp_host, - $settings->smtp_port, - $encryption - ); - $transport->setUsername($settings->smtp_username ?? ''); - $transport->setPassword($settings->smtp_password ?? ''); + if ($useInstanceEmailSettings || $isTransactionalEmail) { + $settings = instanceSettings(); + } else { + $settings = $notifiable->emailNotificationSettings; + } - $mailer = new \Symfony\Component\Mailer\Mailer($transport); + $isResendEnabled = $settings->resend_enabled; + $isSmtpEnabled = $settings->smtp_enabled; - $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; - $fromName = $settings->smtp_from_name ?? 'System'; - $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); - $email = (new \Symfony\Component\Mime\Email) - ->from($from) - ->to(...$recipients) - ->subject($mailMessage->subject) - ->html((string) $mailMessage->render()); + if ($customEmails) { + $recipients = [$customEmails]; + } else { + $recipients = $notifiable->getRecipients(); + } - $mailer->send($email); + // Validate team membership for all recipients + if (count($recipients) === 0) { + throw new Exception('No email recipients found'); + } + + foreach ($recipients as $recipient) { + // Check if the recipient is part of the team + if (! $members->contains('email', $recipient)) { + $emailSettings = $notifiable->emailNotificationSettings; + data_set($emailSettings, 'smtp_password', '********'); + data_set($emailSettings, 'resend_api_key', '********'); + send_internal_notification(sprintf( + "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s", + $recipient, + $team, + get_class($notification), + get_class($notifiable), + json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + )); + throw new Exception('Recipient is not part of the team'); + } + } + + $mailMessage = $notification->toMail($notifiable); + + if ($isResendEnabled) { + $resend = Resend::client($settings->resend_api_key); + $from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>"; + $resend->emails->send([ + 'from' => $from, + 'to' => $recipients, + 'subject' => $mailMessage->subject, + 'html' => (string) $mailMessage->render(), + ]); + } elseif ($isSmtpEnabled) { + $encryption = match (strtolower($settings->smtp_encryption)) { + 'starttls' => null, + 'tls' => 'tls', + 'none' => null, + default => null, + }; + + $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( + $settings->smtp_host, + $settings->smtp_port, + $encryption + ); + $transport->setUsername($settings->smtp_username ?? ''); + $transport->setPassword($settings->smtp_password ?? ''); + + $mailer = new \Symfony\Component\Mailer\Mailer($transport); + + $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; + $fromName = $settings->smtp_from_name ?? 'System'; + $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); + $email = (new \Symfony\Component\Mime\Email) + ->from($from) + ->to(...$recipients) + ->subject($mailMessage->subject) + ->html((string) $mailMessage->render()); + + $mailer->send($email); + } + } catch (\Throwable $e) { + // Check if this is a Resend domain verification error on cloud instances + if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { + // Throw as NonReportableException so it won't go to Sentry + throw NonReportableException::fromException($e); + } + throw $e; } } } diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index ea4ab7171..c2fa3ff10 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -34,6 +34,7 @@ public function send($notifiable, $notification): void \App\Notifications\Server\HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, \App\Notifications\Server\Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, \App\Notifications\Server\Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, + \App\Notifications\Server\ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, default => null, }; diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 114d1f6ed..8ab74a60b 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -16,7 +16,12 @@ public function send(User $notifiable, Notification $notification): void if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { return; } - $email = $notifiable->email; + + // Check if notification has a custom recipient (for email changes) + $email = property_exists($notification, 'newEmail') && $notification->newEmail + ? $notification->newEmail + : $notifiable->email; + if (! $email) { return; } diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php new file mode 100644 index 000000000..8c3e74b17 --- /dev/null +++ b/app/Notifications/Channels/WebhookChannel.php @@ -0,0 +1,37 @@ +webhookNotificationSettings; + + if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) { + if (isDev()) { + ray('Webhook notification skipped - not enabled or no URL configured'); + } + + return; + } + + $payload = $notification->toWebhook(); + + if (isDev()) { + ray('Dispatching webhook notification', [ + 'notification' => get_class($notification), + 'url' => $webhookSettings->webhook_url, + 'payload' => $payload, + ]); + } + + SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url); + } +} diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index f6ae69481..2d7eb58b5 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -102,4 +102,22 @@ public function toSlack(): SlackMessage color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => true, + 'message' => 'Resource restarted automatically', + 'event' => 'container_restarted', + 'container_name' => $this->name, + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + ]; + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index fc9410a85..f518cd2fd 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -102,4 +102,22 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => false, + 'message' => 'Resource stopped unexpectedly', + 'event' => 'container_stopped', + 'container_name' => $this->name, + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + ]; + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index a19fb0431..c2b21b1d5 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -88,4 +88,21 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + return [ + 'success' => false, + 'message' => 'Database backup failed', + 'event' => 'backup_failed', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'database_type' => $this->database_name, + 'frequency' => $this->frequency, + 'error_output' => $this->output, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 78bcfafe3..3d2d8ece3 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -85,4 +85,20 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + return [ + 'success' => true, + 'message' => 'Database backup successful', + 'event' => 'backup_success', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'database_type' => $this->database_name, + 'frequency' => $this->frequency, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php new file mode 100644 index 000000000..ee24ef17d --- /dev/null +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -0,0 +1,139 @@ +onQueue('high'); + + $this->name = $database->name; + $this->frequency = $backup->frequency; + + if ($backup->s3) { + $this->s3_storage_url = base_url().'/storages/'.$backup->s3->uuid; + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}"); + $mail->view('emails.backup-success-with-s3-warning', [ + 'name' => $this->name, + 'database_name' => $this->database_name, + 'frequency' => $this->frequency, + 's3_error' => $this->s3_error, + 's3_storage_url' => $this->s3_storage_url, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: ':warning: Database backup succeeded locally, S3 upload failed', + description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.", + color: DiscordMessage::warningColor(), + ); + + $message->addField('Frequency', $this->frequency, true); + $message->addField('S3 Error', $this->s3_error); + + if ($this->s3_storage_url) { + $message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')'); + } + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}"; + + if ($this->s3_storage_url) { + $message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}"; + } + + return [ + 'message' => $message, + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.

Frequency: {$this->frequency}.
S3 Error: {$this->s3_error}"; + + if ($this->s3_storage_url) { + $message .= "

s3_storage_url}\">Check S3 Configuration"; + } + + return new PushoverMessage( + title: 'Database backup succeeded locally, S3 upload failed', + level: 'warning', + message: $message, + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Database backup succeeded locally, S3 upload failed'; + $description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3."; + + $description .= "\n\n*Frequency:* {$this->frequency}"; + $description .= "\n\n*S3 Error:* {$this->s3_error}"; + + if ($this->s3_storage_url) { + $description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>"; + } + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::warningColor() + ); + } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + $data = [ + 'success' => true, + 'message' => 'Database backup succeeded locally, S3 upload failed', + 'event' => 'backup_success_with_s3_warning', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'database_type' => $this->database_name, + 'frequency' => $this->frequency, + 's3_error' => $this->s3_error, + 'url' => $url, + ]; + + if ($this->s3_storage_url) { + $data['s3_storage_url'] = $this->s3_storage_url; + } + + return $data; + } +} diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index eb4fc7e79..bd060112a 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -114,4 +114,28 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => false, + 'message' => 'Scheduled task failed', + 'event' => 'task_failed', + 'task_name' => $this->task->name, + 'task_uuid' => $this->task->uuid, + 'output' => $this->output, + ]; + + if ($this->task->application) { + $data['application_uuid'] = $this->task->application->uuid; + } elseif ($this->task->service) { + $data['service_uuid'] = $this->task->service->uuid; + } + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index c45784db2..58c959bd8 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -105,4 +105,28 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $data = [ + 'success' => true, + 'message' => 'Scheduled task succeeded', + 'event' => 'task_success', + 'task_name' => $this->task->name, + 'task_uuid' => $this->task->uuid, + 'output' => $this->output, + ]; + + if ($this->task->application) { + $data['application_uuid'] = $this->task->application->uuid; + } elseif ($this->task->service) { + $data['service_uuid'] = $this->task->service->uuid; + } + + if ($this->url) { + $data['url'] = $this->url; + } + + return $data; + } } diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index 0291eed19..9cbdeb488 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -66,4 +66,19 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => false, + 'message' => 'Docker cleanup job failed', + 'event' => 'docker_cleanup_failed', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'error_message' => $this->message, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index 1a652d189..d28f25c6c 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -66,4 +66,19 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => true, + 'message' => 'Docker cleanup job succeeded', + 'event' => 'docker_cleanup_success', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'cleanup_message' => $this->message, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php new file mode 100644 index 000000000..de894331b --- /dev/null +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -0,0 +1,71 @@ +onQueue('high'); + } + + public function via(object $notifiable): array + { + ray('hello'); + ray($notifiable); + + return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}"); + $mail->view('emails.hetzner-deletion-failed', [ + 'hetznerServerId' => $this->hetznerServerId, + 'errorMessage' => $this->errorMessage, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':cross_mark: Coolify: [ACTION REQUIRED] Failed to delete Hetzner server', + description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\n**Error:** {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + color: DiscordMessage::errorColor(), + ); + } + + public function toTelegram(): array + { + return [ + 'message' => "Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + ]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: 'Hetzner Server Deletion Failed', + level: 'error', + message: "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check and manually delete if needed.", + ); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage( + title: 'Coolify: [ACTION REQUIRED] Hetzner Server Deletion Failed', + description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + color: SlackMessage::errorColor() + ); + } +} diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 983e6d81e..149d1bbc8 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -88,4 +88,18 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'High disk usage detected', + 'event' => 'high_disk_usage', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'disk_usage' => $this->disk_usage, + 'threshold' => $this->server_disk_usage_notification_threshold, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index e03aef6b7..e64b0af2a 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -74,4 +74,18 @@ public function toSlack(): SlackMessage color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => true, + 'message' => 'Server revived', + 'event' => 'server_reachable', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php new file mode 100644 index 000000000..4d3053569 --- /dev/null +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -0,0 +1,391 @@ +onQueue('high'); + $this->serverUrl = route('server.security.patches', ['server_uuid' => $this->server->uuid]); + if (isDev()) { + $this->serverUrl = 'https://staging-but-dev.coolify.io/server/'.$this->server->uuid.'/security/patches'; + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('server_patch'); + } + + public function toMail($notifiable = null): MailMessage + { + $mail = new MailMessage; + + // Handle error case + if (isset($this->patchData['error'])) { + $mail->subject("Coolify: [ERROR] Failed to check patches on {$this->server->name}"); + $mail->view('emails.server-patches-error', [ + 'name' => $this->server->name, + 'error' => $this->patchData['error'], + 'osId' => $this->patchData['osId'] ?? 'unknown', + 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', + 'server_url' => $this->serverUrl, + ]); + + return $mail; + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $mail->subject("Coolify: [ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}"); + $mail->view('emails.server-patches', [ + 'name' => $this->server->name, + 'total_updates' => $totalUpdates, + 'updates' => $this->patchData['updates'] ?? [], + 'osId' => $this->patchData['osId'] ?? 'unknown', + 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', + 'server_url' => $this->serverUrl, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + // Handle error case + if (isset($this->patchData['error'])) { + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + $error = $this->patchData['error']; + + $description = "**Failed to check for updates** on server {$this->server->name}\n\n"; + $description .= "**Error Details:**\n"; + $description .= '• OS: '.ucfirst($osId)."\n"; + $description .= "• Package Manager: {$packageManager}\n"; + $description .= "• Error: {$error}\n\n"; + $description .= "[Manage Server]($this->serverUrl)"; + + return new DiscordMessage( + title: ':x: Coolify: [ERROR] Failed to check patches on '.$this->server->name, + description: $description, + color: DiscordMessage::errorColor(), + ); + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $updates = $this->patchData['updates'] ?? []; + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + + $description = "**{$totalUpdates} package updates** available for server {$this->server->name}\n\n"; + $description .= "**Summary:**\n"; + $description .= '• OS: '.ucfirst($osId)."\n"; + $description .= "• Package Manager: {$packageManager}\n"; + $description .= "• Total Updates: {$totalUpdates}\n\n"; + + // Show first few packages + if (count($updates) > 0) { + $description .= "**Sample Updates:**\n"; + $sampleUpdates = array_slice($updates, 0, 5); + foreach ($sampleUpdates as $update) { + $description .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n"; + } + if (count($updates) > 5) { + $description .= '• ... and '.(count($updates) - 5)." more packages\n"; + } + + // Check for critical packages + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); + + if ($criticalPackages->count() > 0) { + $description .= "\n **Critical packages detected** ({$criticalPackages->count()} packages may require restarts)"; + } + $description .= "\n [Manage Server Patches]($this->serverUrl)"; + } + + return new DiscordMessage( + title: ':warning: Coolify: [ACTION REQUIRED] Server patches available on '.$this->server->name, + description: $description, + color: DiscordMessage::errorColor(), + ); + + } + + public function toTelegram(): array + { + // Handle error case + if (isset($this->patchData['error'])) { + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + $error = $this->patchData['error']; + + $message = "❌ Coolify: [ERROR] Failed to check patches on {$this->server->name}!\n\n"; + $message .= "📊 Error Details:\n"; + $message .= '• OS: '.ucfirst($osId)."\n"; + $message .= "• Package Manager: {$packageManager}\n"; + $message .= "• Error: {$error}\n\n"; + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Manage Server', + 'url' => $this->serverUrl, + ], + ], + ]; + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $updates = $this->patchData['updates'] ?? []; + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + + $message = "🔧 Coolify: [ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}!\n\n"; + $message .= "📊 Summary:\n"; + $message .= '• OS: '.ucfirst($osId)."\n"; + $message .= "• Package Manager: {$packageManager}\n"; + $message .= "• Total Updates: {$totalUpdates}\n\n"; + + if (count($updates) > 0) { + $message .= "📦 Sample Updates:\n"; + $sampleUpdates = array_slice($updates, 0, 5); + foreach ($sampleUpdates as $update) { + $message .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n"; + } + if (count($updates) > 5) { + $message .= '• ... and '.(count($updates) - 5)." more packages\n"; + } + + // Check for critical packages + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); + + if ($criticalPackages->count() > 0) { + $message .= "\n⚠️ Critical packages detected: {$criticalPackages->count()} packages may require restarts\n"; + foreach ($criticalPackages->take(3) as $package) { + $message .= "• {$package['package']}: {$package['current_version']} → {$package['new_version']}\n"; + } + if ($criticalPackages->count() > 3) { + $message .= '• ... and '.($criticalPackages->count() - 3)." more critical packages\n"; + } + } + } + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Manage Server Patches', + 'url' => $this->serverUrl, + ], + ], + ]; + } + + public function toPushover(): PushoverMessage + { + // Handle error case + if (isset($this->patchData['error'])) { + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + $error = $this->patchData['error']; + + $message = "[ERROR] Failed to check patches on {$this->server->name}!\n\n"; + $message .= "Error Details:\n"; + $message .= '• OS: '.ucfirst($osId)."\n"; + $message .= "• Package Manager: {$packageManager}\n"; + $message .= "• Error: {$error}\n\n"; + + return new PushoverMessage( + title: 'Server patch check failed', + level: 'error', + message: $message, + buttons: [ + [ + 'text' => 'Manage Server', + 'url' => $this->serverUrl, + ], + ], + ); + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $updates = $this->patchData['updates'] ?? []; + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + + $message = "[ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}!\n\n"; + $message .= "Summary:\n"; + $message .= '• OS: '.ucfirst($osId)."\n"; + $message .= "• Package Manager: {$packageManager}\n"; + $message .= "• Total Updates: {$totalUpdates}\n\n"; + + if (count($updates) > 0) { + $message .= "Sample Updates:\n"; + $sampleUpdates = array_slice($updates, 0, 3); + foreach ($sampleUpdates as $update) { + $message .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n"; + } + if (count($updates) > 3) { + $message .= '• ... and '.(count($updates) - 3)." more packages\n"; + } + + // Check for critical packages + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); + + if ($criticalPackages->count() > 0) { + $message .= "\nCritical packages detected: {$criticalPackages->count()} may require restarts"; + } + } + + return new PushoverMessage( + title: 'Server patches available', + level: 'error', + message: $message, + buttons: [ + [ + 'text' => 'Manage Server Patches', + 'url' => $this->serverUrl, + ], + ], + ); + } + + public function toSlack(): SlackMessage + { + // Handle error case + if (isset($this->patchData['error'])) { + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + $error = $this->patchData['error']; + + $description = "Failed to check patches on '{$this->server->name}'!\n\n"; + $description .= "*Error Details:*\n"; + $description .= '• OS: '.ucfirst($osId)."\n"; + $description .= "• Package Manager: {$packageManager}\n"; + $description .= "• Error: `{$error}`\n\n"; + $description .= "\n:link: <{$this->serverUrl}|Manage Server>"; + + return new SlackMessage( + title: 'Coolify: [ERROR] Server patch check failed', + description: $description, + color: SlackMessage::errorColor() + ); + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $updates = $this->patchData['updates'] ?? []; + $osId = $this->patchData['osId'] ?? 'unknown'; + $packageManager = $this->patchData['package_manager'] ?? 'unknown'; + + $description = "{$totalUpdates} server patches available on '{$this->server->name}'!\n\n"; + $description .= "*Summary:*\n"; + $description .= '• OS: '.ucfirst($osId)."\n"; + $description .= "• Package Manager: {$packageManager}\n"; + $description .= "• Total Updates: {$totalUpdates}\n\n"; + + if (count($updates) > 0) { + $description .= "*Sample Updates:*\n"; + $sampleUpdates = array_slice($updates, 0, 5); + foreach ($sampleUpdates as $update) { + $description .= "• `{$update['package']}`: {$update['current_version']} → {$update['new_version']}\n"; + } + if (count($updates) > 5) { + $description .= '• ... and '.(count($updates) - 5)." more packages\n"; + } + + // Check for critical packages + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); + + if ($criticalPackages->count() > 0) { + $description .= "\n:warning: *Critical packages detected:* {$criticalPackages->count()} packages may require restarts\n"; + foreach ($criticalPackages->take(3) as $package) { + $description .= "• `{$package['package']}`: {$package['current_version']} → {$package['new_version']}\n"; + } + if ($criticalPackages->count() > 3) { + $description .= '• ... and '.($criticalPackages->count() - 3)." more critical packages\n"; + } + } + } + + $description .= "\n:link: <{$this->serverUrl}|Manage Server Patches>"; + + return new SlackMessage( + title: 'Coolify: [ACTION REQUIRED] Server patches available', + description: $description, + color: SlackMessage::errorColor() + ); + } + + public function toWebhook(): array + { + // Handle error case + if (isset($this->patchData['error'])) { + return [ + 'success' => false, + 'message' => 'Failed to check patches', + 'event' => 'server_patch_check_error', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'os_id' => $this->patchData['osId'] ?? 'unknown', + 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', + 'error' => $this->patchData['error'], + 'url' => $this->serverUrl, + ]; + } + + $totalUpdates = $this->patchData['total_updates'] ?? 0; + $updates = $this->patchData['updates'] ?? []; + + // Check for critical packages + $criticalPackages = collect($updates)->filter(function ($update) { + return str_contains(strtolower($update['package']), 'docker') || + str_contains(strtolower($update['package']), 'kernel') || + str_contains(strtolower($update['package']), 'openssh') || + str_contains(strtolower($update['package']), 'ssl'); + }); + + return [ + 'success' => false, + 'message' => 'Server patches available', + 'event' => 'server_patch_check', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'total_updates' => $totalUpdates, + 'os_id' => $this->patchData['osId'] ?? 'unknown', + 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', + 'updates' => $updates, + 'critical_packages_count' => $criticalPackages->count(), + 'url' => $this->serverUrl, + ]; + } +} diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index fe90cc610..99742f3b7 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -82,4 +82,18 @@ public function toSlack(): SlackMessage color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + $url = base_url().'/server/'.$this->server->uuid; + + return [ + 'success' => false, + 'message' => 'Server unreachable', + 'event' => 'server_unreachable', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => $url, + ]; + } } diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index 0b1d8d6b1..60bc8a0ee 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -7,6 +7,7 @@ use App\Notifications\Channels\PushoverChannel; use App\Notifications\Channels\SlackChannel; use App\Notifications\Channels\TelegramChannel; +use App\Notifications\Channels\WebhookChannel; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; @@ -36,6 +37,7 @@ public function via(object $notifiable): array 'telegram' => [TelegramChannel::class], 'slack' => [SlackChannel::class], 'pushover' => [PushoverChannel::class], + 'webhook' => [WebhookChannel::class], default => [], }; } else { @@ -110,4 +112,14 @@ public function toSlack(): SlackMessage description: 'This is a test Slack notification from Coolify.' ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => 'This is a test webhook notification from Coolify.', + 'event' => 'test', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php new file mode 100644 index 000000000..ea8462366 --- /dev/null +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -0,0 +1,43 @@ +onQueue('high'); + } + + public function toMail(): MailMessage + { + // Use the configured expiry minutes value + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + + $mail = new MailMessage; + $mail->subject('Coolify: Verify Your New Email Address'); + $mail->view('emails.email-change-verification', [ + 'newEmail' => $this->newEmail, + 'verificationCode' => $this->verificationCode, + 'expiryMinutes' => $expiryMinutes, + ]); + + return $mail; + } +} diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php new file mode 100644 index 000000000..761227118 --- /dev/null +++ b/app/Policies/ApiTokenPolicy.php @@ -0,0 +1,109 @@ +id === $token->tokenable_id && $token->tokenable_type === User::class; + */ + return true; + } + + /** + * Determine whether the user can create API tokens. + */ + public function create(User $user): bool + { + // Authorization temporarily disabled + /* + // All authenticated users can create their own API tokens + return true; + */ + return true; + } + + /** + * Determine whether the user can update the API token. + */ + public function update(User $user, PersonalAccessToken $token): bool + { + // Authorization temporarily disabled + /* + // Users can only update their own tokens + return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + */ + return true; + } + + /** + * Determine whether the user can delete the API token. + */ + public function delete(User $user, PersonalAccessToken $token): bool + { + // Authorization temporarily disabled + /* + // Users can only delete their own tokens + return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + */ + return true; + } + + /** + * Determine whether the user can manage their own API tokens. + */ + public function manage(User $user): bool + { + // Authorization temporarily disabled + /* + // All authenticated users can manage their own API tokens + return true; + */ + return true; + } + + /** + * Determine whether the user can use root permissions for API tokens. + */ + public function useRootPermissions(User $user): bool + { + // Only admins and owners can use root permissions + return $user->isAdmin() || $user->isOwner(); + } + + /** + * Determine whether the user can use write permissions for API tokens. + */ + public function useWritePermissions(User $user): bool + { + // Authorization temporarily disabled + /* + // Only admins and owners can use write permissions + return $user->isAdmin() || $user->isOwner(); + */ + return true; + } +} diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 05fc289b8..d64a436ad 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\User; +use Illuminate\Auth\Access\Response; class ApplicationPolicy { @@ -12,6 +13,10 @@ class ApplicationPolicy */ public function viewAny(User $user): bool { + // Authorization temporarily disabled + /* + return true; + */ return true; } @@ -20,6 +25,10 @@ public function viewAny(User $user): bool */ public function view(User $user, Application $application): bool { + // Authorization temporarily disabled + /* + return true; + */ return true; } @@ -28,15 +37,31 @@ public function view(User $user, Application $application): bool */ public function create(User $user): bool { + // Authorization temporarily disabled + /* + if ($user->isAdmin()) { + return true; + } + + return false; + */ return true; } /** * Determine whether the user can update the model. */ - public function update(User $user, Application $application): bool + public function update(User $user, Application $application): Response { - return true; + // Authorization temporarily disabled + /* + if ($user->isAdmin()) { + return Response::allow(); + } + + return Response::deny('As a member, you cannot update this application.

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

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

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

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

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

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

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

    ', $html); + $html = preg_replace('/]*>/', '
      ', $html); + $html = preg_replace('/]*>/', '
    1. ', $html); + + // Code blocks and inline code + $html = preg_replace('/]*>/', '
      ', $html);
      +        $html = preg_replace('/]*>/', '', $html);
      +
      +        // Links - Apply styling to existing markdown links
      +        $html = preg_replace('/]*)>/', '', $html);
      +
      +        // Convert plain URLs to clickable links (that aren't already in  tags)
      +        $html = preg_replace('/(?)(?"]+)(?![^<]*<\/a>)/', '$1', $html);
      +
      +        // Strong/bold text
      +        $html = preg_replace('/]*>/', '', $html);
      +
      +        // Emphasis/italic text
      +        $html = preg_replace('/]*>/', '', $html);
      +
      +        return $html;
      +    }
      +}
      diff --git a/app/Services/ConfigurationGenerator.php b/app/Services/ConfigurationGenerator.php
      index a7e4b31be..320e3f32a 100644
      --- a/app/Services/ConfigurationGenerator.php
      +++ b/app/Services/ConfigurationGenerator.php
      @@ -129,7 +129,6 @@ protected function getEnvironmentVariables(): array
                   $variables->push([
                       'key' => $env->key,
                       'value' => $env->value,
      -                'is_build_time' => $env->is_build_time,
                       'is_preview' => $env->is_preview,
                       'is_multiline' => $env->is_multiline,
                   ]);
      @@ -145,7 +144,6 @@ protected function getPreviewEnvironmentVariables(): array
                   $variables->push([
                       'key' => $env->key,
                       'value' => $env->value,
      -                'is_build_time' => $env->is_build_time,
                       'is_preview' => $env->is_preview,
                       'is_multiline' => $env->is_multiline,
                   ]);
      diff --git a/app/Services/DockerImageParser.php b/app/Services/DockerImageParser.php
      index 1fd6625b3..b483c979a 100644
      --- a/app/Services/DockerImageParser.php
      +++ b/app/Services/DockerImageParser.php
      @@ -10,20 +10,33 @@ class DockerImageParser
       
           private string $tag = 'latest';
       
      +    private bool $isImageHash = false;
      +
           public function parse(string $imageString): self
           {
      -        // First split by : to handle the tag, but be careful with registry ports
      -        $lastColon = strrpos($imageString, ':');
      -        $hasSlash = str_contains($imageString, '/');
      -
      -        // If the last colon appears after the last slash, it's a tag
      -        // Otherwise it might be a port in the registry URL
      -        if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
      -            $mainPart = substr($imageString, 0, $lastColon);
      -            $this->tag = substr($imageString, $lastColon + 1);
      +        // Check for @sha256: format first (e.g., nginx@sha256:abc123...)
      +        if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) {
      +            $mainPart = $matches[1];
      +            $this->tag = $matches[2];
      +            $this->isImageHash = true;
               } else {
      -            $mainPart = $imageString;
      -            $this->tag = 'latest';
      +            // Split by : to handle the tag, but be careful with registry ports
      +            $lastColon = strrpos($imageString, ':');
      +            $hasSlash = str_contains($imageString, '/');
      +
      +            // If the last colon appears after the last slash, it's a tag
      +            // Otherwise it might be a port in the registry URL
      +            if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
      +                $mainPart = substr($imageString, 0, $lastColon);
      +                $this->tag = substr($imageString, $lastColon + 1);
      +
      +                // Check if the tag is a SHA256 hash
      +                $this->isImageHash = $this->isSha256Hash($this->tag);
      +            } else {
      +                $mainPart = $imageString;
      +                $this->tag = 'latest';
      +                $this->isImageHash = false;
      +            }
               }
       
               // Split the main part by / to handle registry and image name
      @@ -41,6 +54,37 @@ public function parse(string $imageString): self
               return $this;
           }
       
      +    /**
      +     * Check if the given string is a SHA256 hash
      +     */
      +    private function isSha256Hash(string $hash): bool
      +    {
      +        // SHA256 hashes are 64 characters long and contain only hexadecimal characters
      +        return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1;
      +    }
      +
      +    /**
      +     * Check if the current tag is an image hash
      +     */
      +    public function isImageHash(): bool
      +    {
      +        return $this->isImageHash;
      +    }
      +
      +    /**
      +     * Get the full image name with hash if present
      +     */
      +    public function getFullImageNameWithHash(): string
      +    {
      +        $imageName = $this->getFullImageNameWithoutTag();
      +
      +        if ($this->isImageHash) {
      +            return $imageName.'@sha256:'.$this->tag;
      +        }
      +
      +        return $imageName.':'.$this->tag;
      +    }
      +
           public function getFullImageNameWithoutTag(): string
           {
               if ($this->registryUrl) {
      @@ -73,6 +117,10 @@ public function toString(): string
               }
               $parts[] = $this->imageName;
       
      +        if ($this->isImageHash) {
      +            return implode('/', $parts).'@sha256:'.$this->tag;
      +        }
      +
               return implode('/', $parts).':'.$this->tag;
           }
       }
      diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php
      new file mode 100644
      index 000000000..aa6de3897
      --- /dev/null
      +++ b/app/Services/HetznerService.php
      @@ -0,0 +1,143 @@
      +token = $token;
      +    }
      +
      +    private function request(string $method, string $endpoint, array $data = [])
      +    {
      +        $response = Http::withHeaders([
      +            'Authorization' => 'Bearer '.$this->token,
      +        ])
      +            ->timeout(30)
      +            ->retry(3, function (int $attempt, \Exception $exception) {
      +                // Handle rate limiting (429 Too Many Requests)
      +                if ($exception instanceof \Illuminate\Http\Client\RequestException) {
      +                    $response = $exception->response;
      +
      +                    if ($response && $response->status() === 429) {
      +                        // Get rate limit reset timestamp from headers
      +                        $resetTime = $response->header('RateLimit-Reset');
      +
      +                        if ($resetTime) {
      +                            // Calculate wait time until rate limit resets
      +                            $waitSeconds = max(0, $resetTime - time());
      +
      +                            // Cap wait time at 60 seconds for safety
      +                            return min($waitSeconds, 60) * 1000;
      +                        }
      +                    }
      +                }
      +
      +                // Exponential backoff for other retriable errors: 100ms, 200ms, 400ms
      +                return $attempt * 100;
      +            })
      +            ->{$method}($this->baseUrl.$endpoint, $data);
      +
      +        if (! $response->successful()) {
      +            throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
      +        }
      +
      +        return $response->json();
      +    }
      +
      +    private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array
      +    {
      +        $allResults = [];
      +        $page = 1;
      +
      +        do {
      +            $data['page'] = $page;
      +            $data['per_page'] = 50;
      +
      +            $response = $this->request($method, $endpoint, $data);
      +
      +            if (isset($response[$resourceKey])) {
      +                $allResults = array_merge($allResults, $response[$resourceKey]);
      +            }
      +
      +            $nextPage = $response['meta']['pagination']['next_page'] ?? null;
      +            $page = $nextPage;
      +        } while ($nextPage !== null);
      +
      +        return $allResults;
      +    }
      +
      +    public function getLocations(): array
      +    {
      +        return $this->requestPaginated('get', '/locations', 'locations');
      +    }
      +
      +    public function getImages(): array
      +    {
      +        return $this->requestPaginated('get', '/images', 'images', [
      +            'type' => 'system',
      +        ]);
      +    }
      +
      +    public function getServerTypes(): array
      +    {
      +        return $this->requestPaginated('get', '/server_types', 'server_types');
      +    }
      +
      +    public function getSshKeys(): array
      +    {
      +        return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
      +    }
      +
      +    public function uploadSshKey(string $name, string $publicKey): array
      +    {
      +        $response = $this->request('post', '/ssh_keys', [
      +            'name' => $name,
      +            'public_key' => $publicKey,
      +        ]);
      +
      +        return $response['ssh_key'] ?? [];
      +    }
      +
      +    public function createServer(array $params): array
      +    {
      +        ray('Hetzner createServer request', [
      +            'endpoint' => '/servers',
      +            'params' => $params,
      +        ]);
      +
      +        $response = $this->request('post', '/servers', $params);
      +
      +        ray('Hetzner createServer response', [
      +            'response' => $response,
      +        ]);
      +
      +        return $response['server'] ?? [];
      +    }
      +
      +    public function getServer(int $serverId): array
      +    {
      +        $response = $this->request('get', "/servers/{$serverId}");
      +
      +        return $response['server'] ?? [];
      +    }
      +
      +    public function powerOnServer(int $serverId): array
      +    {
      +        $response = $this->request('post', "/servers/{$serverId}/actions/poweron");
      +
      +        return $response['action'] ?? [];
      +    }
      +
      +    public function deleteServer(int $serverId): void
      +    {
      +        $this->request('delete', "/servers/{$serverId}");
      +    }
      +}
      diff --git a/app/Services/ProxyDashboardCacheService.php b/app/Services/ProxyDashboardCacheService.php
      new file mode 100644
      index 000000000..5d31f9b08
      --- /dev/null
      +++ b/app/Services/ProxyDashboardCacheService.php
      @@ -0,0 +1,57 @@
      +id}:traefik:dashboard_available";
      +    }
      +
      +    /**
      +     * Check if Traefik dashboard is available from configuration
      +     */
      +    public static function isTraefikDashboardAvailableFromConfiguration(Server $server, string $proxy_configuration): void
      +    {
      +        $cacheKey = static::getCacheKey($server);
      +        $dashboardAvailable = str($proxy_configuration)->contains('--api.dashboard=true') &&
      +        str($proxy_configuration)->contains('--api.insecure=true');
      +        Cache::forever($cacheKey, $dashboardAvailable);
      +    }
      +
      +    /**
      +     * Check if Traefik dashboard is available (from cache or compute)
      +     */
      +    public static function isTraefikDashboardAvailableFromCache(Server $server): bool
      +    {
      +        $cacheKey = static::getCacheKey($server);
      +
      +        return Cache::get($cacheKey) ?? false;
      +    }
      +
      +    /**
      +     * Clear Traefik dashboard cache for a server
      +     */
      +    public static function clearCache(Server $server): void
      +    {
      +        Cache::forget(static::getCacheKey($server));
      +    }
      +
      +    /**
      +     * Clear Traefik dashboard cache for multiple servers
      +     */
      +    public static function clearCacheForServers(array $serverIds): void
      +    {
      +        foreach ($serverIds as $serverId) {
      +            $cacheKey = "server:{$serverId}:traefik:dashboard_available";
      +            Cache::forget($cacheKey);
      +        }
      +    }
      +}
      diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
      new file mode 100644
      index 000000000..965142558
      --- /dev/null
      +++ b/app/Support/ValidationPatterns.php
      @@ -0,0 +1,93 @@
      + 'The name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
      +            'name.min' => 'The name must be at least :min characters.',
      +            'name.max' => 'The name may not be greater than :max characters.',
      +        ];
      +    }
      +
      +    /**
      +     * Get validation messages for description fields
      +     */
      +    public static function descriptionMessages(): array
      +    {
      +        return [
      +            'description.regex' => 'The description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
      +            'description.max' => 'The description may not be greater than :max characters.',
      +        ];
      +    }
      +
      +    /**
      +     * Get combined validation messages for both name and description fields
      +     */
      +    public static function combinedMessages(): array
      +    {
      +        return array_merge(self::nameMessages(), self::descriptionMessages());
      +    }
      +}
      diff --git a/app/Traits/AuthorizesResourceCreation.php b/app/Traits/AuthorizesResourceCreation.php
      new file mode 100644
      index 000000000..01ae7c8d9
      --- /dev/null
      +++ b/app/Traits/AuthorizesResourceCreation.php
      @@ -0,0 +1,20 @@
      +authorize('createAnyResource');
      +    }
      +}
      diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php
      new file mode 100644
      index 000000000..b9af70aba
      --- /dev/null
      +++ b/app/Traits/ClearsGlobalSearchCache.php
      @@ -0,0 +1,128 @@
      +hasSearchableChanges()) {
      +                    $teamId = $model->getTeamIdForCache();
      +                    if (filled($teamId)) {
      +                        GlobalSearch::clearTeamCache($teamId);
      +                    }
      +                }
      +            } catch (\Throwable $e) {
      +                // Silently fail cache clearing - don't break the save operation
      +                ray('Failed to clear global search cache on saving: '.$e->getMessage());
      +            }
      +        });
      +
      +        static::created(function ($model) {
      +            try {
      +                // Always clear cache when model is created
      +                $teamId = $model->getTeamIdForCache();
      +                if (filled($teamId)) {
      +                    GlobalSearch::clearTeamCache($teamId);
      +                }
      +            } catch (\Throwable $e) {
      +                // Silently fail cache clearing - don't break the create operation
      +                ray('Failed to clear global search cache on creation: '.$e->getMessage());
      +            }
      +        });
      +
      +        static::deleted(function ($model) {
      +            try {
      +                // Always clear cache when model is deleted
      +                $teamId = $model->getTeamIdForCache();
      +                if (filled($teamId)) {
      +                    GlobalSearch::clearTeamCache($teamId);
      +                }
      +            } catch (\Throwable $e) {
      +                // Silently fail cache clearing - don't break the delete operation
      +                ray('Failed to clear global search cache on deletion: '.$e->getMessage());
      +            }
      +        });
      +    }
      +
      +    private function hasSearchableChanges(): bool
      +    {
      +        try {
      +            // Define searchable fields based on model type
      +            $searchableFields = ['name', 'description'];
      +
      +            // Add model-specific searchable fields
      +            if ($this instanceof \App\Models\Application) {
      +                $searchableFields[] = 'fqdn';
      +                $searchableFields[] = 'docker_compose_domains';
      +            } elseif ($this instanceof \App\Models\Server) {
      +                $searchableFields[] = 'ip';
      +            } elseif ($this instanceof \App\Models\Service) {
      +                // Services don't have direct fqdn, but name and description are covered
      +            } elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) {
      +                // Projects and environments only have name and description as searchable
      +            }
      +            // Database models only have name and description as searchable
      +
      +            // Check if any searchable field is dirty
      +            foreach ($searchableFields as $field) {
      +                // Check if attribute exists before checking if dirty
      +                if (array_key_exists($field, $this->getAttributes()) && $this->isDirty($field)) {
      +                    return true;
      +                }
      +            }
      +
      +            return false;
      +        } catch (\Throwable $e) {
      +            // If checking changes fails, assume changes exist to be safe
      +            ray('Failed to check searchable changes: '.$e->getMessage());
      +
      +            return true;
      +        }
      +    }
      +
      +    private function getTeamIdForCache()
      +    {
      +        try {
      +            // For Project models (has direct team_id)
      +            if ($this instanceof \App\Models\Project) {
      +                return $this->team_id ?? null;
      +            }
      +
      +            // For Environment models (get team_id through project)
      +            if ($this instanceof \App\Models\Environment) {
      +                return $this->project?->team_id;
      +            }
      +
      +            // For database models, team is accessed through environment.project.team
      +            if (method_exists($this, 'team')) {
      +                if ($this instanceof \App\Models\Server) {
      +                    $team = $this->team;
      +                } else {
      +                    $team = $this->team();
      +                }
      +                if (filled($team)) {
      +                    return is_object($team) ? $team->id : null;
      +                }
      +            }
      +
      +            // For models with direct team_id property
      +            if (property_exists($this, 'team_id') || isset($this->team_id)) {
      +                return $this->team_id ?? null;
      +            }
      +
      +            return null;
      +        } catch (\Throwable $e) {
      +            // If we can't determine team ID, return null
      +            ray('Failed to get team ID for cache: '.$e->getMessage());
      +
      +            return null;
      +        }
      +    }
      +}
      diff --git a/app/Traits/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php
      index a4d3a7cfd..e9ec0d946 100644
      --- a/app/Traits/DeletesUserSessions.php
      +++ b/app/Traits/DeletesUserSessions.php
      @@ -26,7 +26,7 @@ protected static function bootDeletesUserSessions()
           {
               static::updated(function ($user) {
                   // Check if password was changed
      -            if ($user->isDirty('password')) {
      +            if ($user->wasChanged('password')) {
                       $user->deleteAllSessions();
                   }
               });
      diff --git a/app/Traits/EnvironmentVariableAnalyzer.php b/app/Traits/EnvironmentVariableAnalyzer.php
      new file mode 100644
      index 000000000..0b452a940
      --- /dev/null
      +++ b/app/Traits/EnvironmentVariableAnalyzer.php
      @@ -0,0 +1,221 @@
      + [
      +                'problematic_values' => ['production', 'prod'],
      +                'affects' => 'Node.js/npm/yarn/bun/pnpm',
      +                'issue' => 'Skips devDependencies installation which are often required for building (webpack, typescript, etc.)',
      +                'recommendation' => 'Uncheck "Available at Buildtime" or use "development" during build',
      +            ],
      +            'NPM_CONFIG_PRODUCTION' => [
      +                'problematic_values' => ['true', '1', 'yes'],
      +                'affects' => 'npm/pnpm',
      +                'issue' => 'Forces npm to skip devDependencies',
      +                'recommendation' => 'Remove from build-time variables or set to false',
      +            ],
      +            'YARN_PRODUCTION' => [
      +                'problematic_values' => ['true', '1', 'yes'],
      +                'affects' => 'Yarn/pnpm',
      +                'issue' => 'Forces yarn to skip devDependencies',
      +                'recommendation' => 'Remove from build-time variables or set to false',
      +            ],
      +            'COMPOSER_NO_DEV' => [
      +                'problematic_values' => ['1', 'true', 'yes'],
      +                'affects' => 'PHP/Composer',
      +                'issue' => 'Skips require-dev packages which may include build tools',
      +                'recommendation' => 'Set as "Runtime only" or remove from build-time variables',
      +            ],
      +            'MIX_ENV' => [
      +                'problematic_values' => ['prod', 'production'],
      +                'affects' => 'Elixir/Phoenix',
      +                'issue' => 'Production mode may skip development dependencies needed for compilation',
      +                'recommendation' => 'Use "dev" for build or set as "Runtime only"',
      +            ],
      +            'RAILS_ENV' => [
      +                'problematic_values' => ['production'],
      +                'affects' => 'Ruby on Rails',
      +                'issue' => 'May affect asset precompilation and dependency handling',
      +                'recommendation' => 'Consider using "development" for build phase',
      +            ],
      +            'RACK_ENV' => [
      +                'problematic_values' => ['production'],
      +                'affects' => 'Ruby/Rack',
      +                'issue' => 'May affect dependency handling and build behavior',
      +                'recommendation' => 'Consider using "development" for build phase',
      +            ],
      +            'BUNDLE_WITHOUT' => [
      +                'problematic_values' => ['development', 'test', 'development:test'],
      +                'affects' => 'Ruby/Bundler',
      +                'issue' => 'Excludes gem groups that may contain build dependencies',
      +                'recommendation' => 'Remove from build-time variables or adjust groups',
      +            ],
      +            'FLASK_ENV' => [
      +                'problematic_values' => ['production'],
      +                'affects' => 'Python/Flask',
      +                'issue' => 'May affect debug mode and development tools availability',
      +                'recommendation' => 'Usually safe, but consider "development" for complex builds',
      +            ],
      +            'DJANGO_SETTINGS_MODULE' => [
      +                'problematic_values' => [], // Check if contains 'production' or 'prod'
      +                'affects' => 'Python/Django',
      +                'issue' => 'Production settings may disable debug tools needed during build',
      +                'recommendation' => 'Use development settings for build phase',
      +                'check_function' => 'checkDjangoSettings',
      +            ],
      +            'APP_ENV' => [
      +                'problematic_values' => ['production', 'prod'],
      +                'affects' => 'Laravel/Symfony',
      +                'issue' => 'May affect dependency installation and build optimizations',
      +                'recommendation' => 'Consider using "local" or "development" for build',
      +            ],
      +            'ASPNETCORE_ENVIRONMENT' => [
      +                'problematic_values' => ['Production'],
      +                'affects' => '.NET/ASP.NET Core',
      +                'issue' => 'May affect build-time configurations and optimizations',
      +                'recommendation' => 'Usually safe, but verify build requirements',
      +            ],
      +            'CI' => [
      +                'problematic_values' => ['true', '1', 'yes'],
      +                'affects' => 'Various tools',
      +                'issue' => 'Changes behavior in many tools (disables interactivity, changes caching)',
      +                'recommendation' => 'Usually beneficial for builds, but be aware of behavior changes',
      +            ],
      +        ];
      +    }
      +
      +    /**
      +     * Analyze an environment variable for potential build issues.
      +     * Always returns a warning if the key is in our list, regardless of value.
      +     */
      +    public static function analyzeBuildVariable(string $key, string $value): ?array
      +    {
      +        $problematicVars = self::getProblematicBuildVariables();
      +
      +        // Direct key match
      +        if (isset($problematicVars[$key])) {
      +            $config = $problematicVars[$key];
      +
      +            // Check if it has a custom check function
      +            if (isset($config['check_function'])) {
      +                $method = $config['check_function'];
      +                if (method_exists(self::class, $method)) {
      +                    return self::{$method}($key, $value, $config);
      +                }
      +            }
      +
      +            // Always return warning for known problematic variables
      +            return [
      +                'variable' => $key,
      +                'value' => $value,
      +                'affects' => $config['affects'],
      +                'issue' => $config['issue'],
      +                'recommendation' => $config['recommendation'],
      +            ];
      +        }
      +
      +        return null;
      +    }
      +
      +    /**
      +     * Analyze multiple environment variables for potential build issues.
      +     */
      +    public static function analyzeBuildVariables(array $variables): array
      +    {
      +        $warnings = [];
      +
      +        foreach ($variables as $key => $value) {
      +            $warning = self::analyzeBuildVariable($key, $value);
      +            if ($warning) {
      +                $warnings[] = $warning;
      +            }
      +        }
      +
      +        return $warnings;
      +    }
      +
      +    /**
      +     * Custom check for Django settings module.
      +     */
      +    protected static function checkDjangoSettings(string $key, string $value, array $config): ?array
      +    {
      +        // Always return warning for DJANGO_SETTINGS_MODULE when it's set as build-time
      +        return [
      +            'variable' => $key,
      +            'value' => $value,
      +            'affects' => $config['affects'],
      +            'issue' => $config['issue'],
      +            'recommendation' => $config['recommendation'],
      +        ];
      +    }
      +
      +    /**
      +     * Generate a formatted warning message for deployment logs.
      +     */
      +    public static function formatBuildWarning(array $warning): array
      +    {
      +        $messages = [
      +            "⚠️ Build-time environment variable warning: {$warning['variable']}={$warning['value']}",
      +            "   Affects: {$warning['affects']}",
      +            "   Issue: {$warning['issue']}",
      +            "   Recommendation: {$warning['recommendation']}",
      +        ];
      +
      +        return $messages;
      +    }
      +
      +    /**
      +     * Check if a variable should show a warning in the UI.
      +     */
      +    public static function shouldShowBuildWarning(string $key): bool
      +    {
      +        return isset(self::getProblematicBuildVariables()[$key]);
      +    }
      +
      +    /**
      +     * Get UI warning message for a specific variable.
      +     */
      +    public static function getUIWarningMessage(string $key): ?string
      +    {
      +        $problematicVars = self::getProblematicBuildVariables();
      +
      +        if (! isset($problematicVars[$key])) {
      +            return null;
      +        }
      +
      +        $config = $problematicVars[$key];
      +        $problematicValuesStr = implode(', ', $config['problematic_values']);
      +
      +        return "Setting {$key} to {$problematicValuesStr} as a build-time variable may cause issues. {$config['issue']} Consider: {$config['recommendation']}";
      +    }
      +
      +    /**
      +     * Get problematic variables configuration for frontend use.
      +     */
      +    public static function getProblematicVariablesForFrontend(): array
      +    {
      +        $vars = self::getProblematicBuildVariables();
      +        $result = [];
      +
      +        foreach ($vars as $key => $config) {
      +            // Skip the check_function as it's PHP-specific
      +            $result[$key] = [
      +                'problematic_values' => $config['problematic_values'],
      +                'affects' => $config['affects'],
      +                'issue' => $config['issue'],
      +                'recommendation' => $config['recommendation'],
      +            ];
      +        }
      +
      +        return $result;
      +    }
      +}
      diff --git a/app/Traits/EnvironmentVariableProtection.php b/app/Traits/EnvironmentVariableProtection.php
      index b6b8d2687..ecc484966 100644
      --- a/app/Traits/EnvironmentVariableProtection.php
      +++ b/app/Traits/EnvironmentVariableProtection.php
      @@ -14,7 +14,7 @@ trait EnvironmentVariableProtection
            */
           protected function isProtectedEnvironmentVariable(string $key): bool
           {
      -        return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
      +        return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_');
           }
       
           /**
      diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
      index f8ccee9db..4aa5aae8b 100644
      --- a/app/Traits/ExecuteRemoteCommand.php
      +++ b/app/Traits/ExecuteRemoteCommand.php
      @@ -11,10 +11,52 @@
       
       trait ExecuteRemoteCommand
       {
      +    use SshRetryable;
      +
           public ?string $save = null;
       
           public static int $batch_counter = 0;
       
      +    private function redact_sensitive_info($text)
      +    {
      +        $text = remove_iip($text);
      +
      +        if (! isset($this->application)) {
      +            return $text;
      +        }
      +
      +        $lockedVars = collect([]);
      +
      +        if (isset($this->application->environment_variables)) {
      +            $lockedVars = $lockedVars->merge(
      +                $this->application->environment_variables
      +                    ->where('is_shown_once', true)
      +                    ->pluck('real_value', 'key')
      +                    ->filter()
      +            );
      +        }
      +
      +        if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
      +            $lockedVars = $lockedVars->merge(
      +                $this->application->environment_variables_preview
      +                    ->where('is_shown_once', true)
      +                    ->pluck('real_value', 'key')
      +                    ->filter()
      +            );
      +        }
      +
      +        foreach ($lockedVars as $key => $value) {
      +            $escapedValue = preg_quote($value, '/');
      +            $text = preg_replace(
      +                '/'.$escapedValue.'/',
      +                REDACTED,
      +                $text
      +            );
      +        }
      +
      +        return $text;
      +    }
      +
           public function execute_remote_command(...$commands)
           {
               static::$batch_counter++;
      @@ -43,54 +85,188 @@ public function execute_remote_command(...$commands)
                           $command = parseLineForSudo($command, $this->server);
                       }
                   }
      -            $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
      -            $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
      -                $output = str($output)->trim();
      -                if ($output->startsWith('╔')) {
      -                    $output = "\n".$output;
      -                }
      -                $new_log_entry = [
      -                    'command' => remove_iip($command),
      -                    'output' => remove_iip($output),
      -                    'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
      -                    'timestamp' => Carbon::now('UTC'),
      -                    'hidden' => $hidden,
      -                    'batch' => static::$batch_counter,
      -                ];
      -                if (! $this->application_deployment_queue->logs) {
      -                    $new_log_entry['order'] = 1;
      -                } else {
      -                    $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
      -                    $new_log_entry['order'] = count($previous_logs) + 1;
      -                }
      -                $previous_logs[] = $new_log_entry;
      -                $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
      -                $this->application_deployment_queue->save();
       
      -                if ($this->save) {
      -                    if (data_get($this->saved_outputs, $this->save, null) === null) {
      -                        data_set($this->saved_outputs, $this->save, str());
      -                    }
      -                    if ($append) {
      -                        $this->saved_outputs[$this->save] .= str($output)->trim();
      -                        $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]);
      +            // Check for cancellation before executing commands
      +            if (isset($this->application_deployment_queue)) {
      +                $this->application_deployment_queue->refresh();
      +                if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
      +                    throw new \RuntimeException('Deployment cancelled by user', 69420);
      +                }
      +            }
      +
      +            $maxRetries = config('constants.ssh.max_retries');
      +            $attempt = 0;
      +            $lastError = null;
      +            $commandExecuted = false;
      +
      +            while ($attempt < $maxRetries && ! $commandExecuted) {
      +                try {
      +                    $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
      +                    $commandExecuted = true;
      +                } catch (\RuntimeException $e) {
      +                    $lastError = $e;
      +                    $errorMessage = $e->getMessage();
      +                    // Only retry if it's an SSH connection error and we haven't exhausted retries
      +                    if ($this->isRetryableSshError($errorMessage) && $attempt < $maxRetries - 1) {
      +                        $attempt++;
      +                        $delay = $this->calculateRetryDelay($attempt - 1);
      +
      +                        // Track SSH retry event in Sentry
      +                        $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
      +                            'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
      +                            'command' => $this->redact_sensitive_info($command),
      +                            'trait' => 'ExecuteRemoteCommand',
      +                        ]);
      +
      +                        // Add log entry for the retry
      +                        if (isset($this->application_deployment_queue)) {
      +                            $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
      +
      +                            // Check for cancellation during retry wait
      +                            $this->application_deployment_queue->refresh();
      +                            if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
      +                                throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
      +                            }
      +                        }
      +
      +                        sleep($delay);
                           } else {
      -                        $this->saved_outputs[$this->save] = str($output)->trim();
      +                        // Not retryable or max retries reached
      +                        throw $e;
                           }
                       }
      -            });
      -            $this->application_deployment_queue->update([
      -                'current_process_id' => $process->id(),
      -            ]);
      +            }
       
      -            $process_result = $process->wait();
      -            if ($process_result->exitCode() !== 0) {
      -                if (! $ignore_errors) {
      +            // If we exhausted all retries and still failed
      +            if (! $commandExecuted && $lastError) {
      +                // Now we can set the status to FAILED since all retries have been exhausted
      +                if (isset($this->application_deployment_queue)) {
                           $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
                           $this->application_deployment_queue->save();
      -                    throw new \RuntimeException($process_result->errorOutput());
                       }
      +                throw $lastError;
                   }
               });
           }
      +
      +    /**
      +     * Execute the actual command with process handling
      +     */
      +    private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors)
      +    {
      +        $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
      +        $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
      +            $output = str($output)->trim();
      +            if ($output->startsWith('╔')) {
      +                $output = "\n".$output;
      +            }
      +
      +            // Sanitize output to ensure valid UTF-8 encoding before JSON encoding
      +            $sanitized_output = sanitize_utf8_text($output);
      +
      +            $new_log_entry = [
      +                'command' => $this->redact_sensitive_info($command),
      +                'output' => $this->redact_sensitive_info($sanitized_output),
      +                'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
      +                'timestamp' => Carbon::now('UTC'),
      +                'hidden' => $hidden,
      +                'batch' => static::$batch_counter,
      +            ];
      +            if (! $this->application_deployment_queue->logs) {
      +                $new_log_entry['order'] = 1;
      +            } else {
      +                try {
      +                    $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
      +                } catch (\JsonException $e) {
      +                    // If existing logs are corrupted, start fresh
      +                    $previous_logs = [];
      +                    $new_log_entry['order'] = 1;
      +                }
      +                if (is_array($previous_logs)) {
      +                    $new_log_entry['order'] = count($previous_logs) + 1;
      +                } else {
      +                    $previous_logs = [];
      +                    $new_log_entry['order'] = 1;
      +                }
      +            }
      +            $previous_logs[] = $new_log_entry;
      +
      +            try {
      +                $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
      +            } catch (\JsonException $e) {
      +                // If JSON encoding still fails, use fallback with invalid sequences replacement
      +                $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
      +            }
      +
      +            $this->application_deployment_queue->save();
      +
      +            if ($this->save) {
      +                if (data_get($this->saved_outputs, $this->save, null) === null) {
      +                    $this->saved_outputs->put($this->save, str());
      +                }
      +                if ($append) {
      +                    $current_value = $this->saved_outputs->get($this->save);
      +                    $this->saved_outputs->put($this->save, str($current_value.str($sanitized_output)->trim()));
      +                } else {
      +                    $this->saved_outputs->put($this->save, str($sanitized_output)->trim());
      +                }
      +            }
      +        });
      +        $this->application_deployment_queue->update([
      +            'current_process_id' => $process->id(),
      +        ]);
      +
      +        $process_result = $process->wait();
      +        if ($process_result->exitCode() !== 0) {
      +            if (! $ignore_errors) {
      +                // Don't immediately set to FAILED - let the retry logic handle it
      +                // This prevents premature status changes during retryable SSH errors
      +                throw new \RuntimeException($process_result->errorOutput());
      +            }
      +        }
      +    }
      +
      +    /**
      +     * Add a log entry for SSH retry attempts
      +     */
      +    private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, string $errorMessage)
      +    {
      +        $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
      +
      +        $new_log_entry = [
      +            'output' => $this->redact_sensitive_info($retryMessage),
      +            'type' => 'stdout',
      +            'timestamp' => Carbon::now('UTC'),
      +            'hidden' => false,
      +            'batch' => static::$batch_counter,
      +        ];
      +
      +        if (! $this->application_deployment_queue->logs) {
      +            $new_log_entry['order'] = 1;
      +            $previous_logs = [];
      +        } else {
      +            try {
      +                $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
      +            } catch (\JsonException $e) {
      +                $previous_logs = [];
      +                $new_log_entry['order'] = 1;
      +            }
      +            if (is_array($previous_logs)) {
      +                $new_log_entry['order'] = count($previous_logs) + 1;
      +            } else {
      +                $previous_logs = [];
      +                $new_log_entry['order'] = 1;
      +            }
      +        }
      +
      +        $previous_logs[] = $new_log_entry;
      +
      +        try {
      +            $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
      +        } catch (\JsonException $e) {
      +            $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
      +        }
      +
      +        $this->application_deployment_queue->save();
      +    }
       }
      diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php
      index 236e4d97c..fded435fd 100644
      --- a/app/Traits/HasNotificationSettings.php
      +++ b/app/Traits/HasNotificationSettings.php
      @@ -7,6 +7,7 @@
       use App\Notifications\Channels\PushoverChannel;
       use App\Notifications\Channels\SlackChannel;
       use App\Notifications\Channels\TelegramChannel;
      +use App\Notifications\Channels\WebhookChannel;
       use Illuminate\Database\Eloquent\Model;
       
       trait HasNotificationSettings
      @@ -17,6 +18,7 @@ trait HasNotificationSettings
               'general',
               'test',
               'ssl_certificate_renewal',
      +        'hetzner_deletion_failure',
           ];
       
           /**
      @@ -30,6 +32,7 @@ public function getNotificationSettings(string $channel): ?Model
                   'telegram' => $this->telegramNotificationSettings,
                   'slack' => $this->slackNotificationSettings,
                   'pushover' => $this->pushoverNotificationSettings,
      +            'webhook' => $this->webhookNotificationSettings,
                   default => null,
               };
           }
      @@ -77,6 +80,7 @@ public function getEnabledChannels(string $event): array
                   'telegram' => TelegramChannel::class,
                   'slack' => SlackChannel::class,
                   'pushover' => PushoverChannel::class,
      +            'webhook' => WebhookChannel::class,
               ];
       
               if ($event === 'general') {
      diff --git a/app/Traits/HasSafeStringAttribute.php b/app/Traits/HasSafeStringAttribute.php
      new file mode 100644
      index 000000000..8a5d2ce77
      --- /dev/null
      +++ b/app/Traits/HasSafeStringAttribute.php
      @@ -0,0 +1,25 @@
      +attributes['name'] = $this->customizeName($sanitized);
      +    }
      +
      +    protected function customizeName($value)
      +    {
      +        return $value; // Default: no customization
      +    }
      +
      +    public function setDescriptionAttribute($value)
      +    {
      +        $this->attributes['description'] = strip_tags($value);
      +    }
      +}
      diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php
      new file mode 100644
      index 000000000..a26481056
      --- /dev/null
      +++ b/app/Traits/SshRetryable.php
      @@ -0,0 +1,174 @@
      +getMessage();
      +
      +                // Check if it's retryable and not the last attempt
      +                if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
      +                    $delay = $this->calculateRetryDelay($attempt);
      +
      +                    // Track SSH retry event in Sentry
      +                    $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
      +
      +                    // Add deployment log if available (for ExecuteRemoteCommand trait)
      +                    if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
      +                        $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
      +                    }
      +
      +                    sleep($delay);
      +
      +                    continue;
      +                }
      +
      +                // Not retryable or max retries reached
      +                break;
      +            }
      +        }
      +
      +        // All retries exhausted
      +        if ($attempt >= $maxRetries) {
      +            Log::error('SSH operation failed after all retries', array_merge($context, [
      +                'attempts' => $attempt,
      +                'error' => $lastErrorMessage,
      +            ]));
      +        }
      +
      +        if ($throwError && $lastError) {
      +            // If the error message is empty, provide a more meaningful one
      +            if (empty($lastErrorMessage) || trim($lastErrorMessage) === '') {
      +                $contextInfo = isset($context['server']) ? " to server {$context['server']}" : '';
      +                $attemptInfo = $attempt > 1 ? " after {$attempt} attempts" : '';
      +                throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode());
      +            }
      +            throw $lastError;
      +        }
      +
      +        return null;
      +    }
      +
      +    /**
      +     * Track SSH retry event in Sentry
      +     */
      +    protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
      +    {
      +        // Only track in production/cloud instances
      +        if (isDev() || ! config('constants.sentry.sentry_dsn')) {
      +            return;
      +        }
      +
      +        try {
      +            app('sentry')->captureMessage(
      +                'SSH connection retry triggered',
      +                \Sentry\Severity::warning(),
      +                [
      +                    'extra' => [
      +                        'attempt' => $attempt,
      +                        'max_retries' => $maxRetries,
      +                        'delay_seconds' => $delay,
      +                        'error_message' => $errorMessage,
      +                        'context' => $context,
      +                        'retryable_error' => true,
      +                    ],
      +                    'tags' => [
      +                        'component' => 'ssh_retry',
      +                        'error_type' => 'connection_retry',
      +                    ],
      +                ]
      +            );
      +        } catch (\Throwable $e) {
      +            // Don't let Sentry tracking errors break the SSH retry flow
      +            Log::warning('Failed to track SSH retry event in Sentry', [
      +                'error' => $e->getMessage(),
      +                'original_attempt' => $attempt,
      +            ]);
      +        }
      +    }
      +}
      diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php
      index bf88d3f88..b54444261 100644
      --- a/app/View/Components/Forms/Button.php
      +++ b/app/View/Components/Forms/Button.php
      @@ -4,6 +4,7 @@
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       
       class Button extends Component
      @@ -17,7 +18,19 @@ public function __construct(
               public ?string $modalId = null,
               public string $defaultClass = 'button',
               public bool $showLoadingIndicator = true,
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
      +
               if ($this->noStyle) {
                   $this->defaultClass = '';
               }
      diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php
      index 8db739642..eb38d84af 100644
      --- a/app/View/Components/Forms/Checkbox.php
      +++ b/app/View/Components/Forms/Checkbox.php
      @@ -4,10 +4,16 @@
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
      +use Visus\Cuid2\Cuid2;
       
       class Checkbox extends Component
       {
      +    public ?string $modelBinding = null;
      +
      +    public ?string $htmlId = null;
      +
           /**
            * Create a new component instance.
            */
      @@ -21,8 +27,21 @@ public function __construct(
               public string|bool|null $checked = false,
               public string|bool $instantSave = false,
               public bool $disabled = false,
      -        public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 focus:ring-warning dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed',
      +        public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base',
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +                $this->instantSave = false; // Disable instant save for unauthorized users
      +            }
      +        }
      +
               if ($this->disabled) {
                   $this->defaultClass .= ' opacity-40';
               }
      @@ -33,6 +52,18 @@ public function __construct(
            */
           public function render(): View|Closure|string
           {
      +        // Store original ID for wire:model binding (property name)
      +        $this->modelBinding = $this->id;
      +
      +        // 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;
      +            $this->htmlId = $this->id.'-'.$uniqueSuffix;
      +        } else {
      +            $this->htmlId = $this->id;
      +        }
      +
               return view('components.forms.checkbox');
           }
       }
      diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php
      index 25643753d..3b7a9ee34 100644
      --- a/app/View/Components/Forms/Datalist.php
      +++ b/app/View/Components/Forms/Datalist.php
      @@ -4,12 +4,16 @@
       
       use Closure;
       use Illuminate\Contracts\View\View;
      -use Illuminate\Support\Str;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
       class Datalist extends Component
       {
      +    public ?string $modelBinding = null;
      +
      +    public ?string $htmlId = null;
      +
           /**
            * Create a new component instance.
            */
      @@ -19,9 +23,27 @@ public function __construct(
               public ?string $label = null,
               public ?string $helper = null,
               public bool $required = false,
      -        public string $defaultClass = 'input'
      +        public bool $disabled = false,
      +        public bool $readonly = false,
      +        public bool $multiple = false,
      +        public string|bool $instantSave = false,
      +        public ?string $value = null,
      +        public ?string $placeholder = null,
      +        public bool $autofocus = false,
      +        public string $defaultClass = 'input',
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      -        //
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +                $this->instantSave = false; // Disable instant save for unauthorized users
      +            }
      +        }
           }
       
           /**
      @@ -29,14 +51,28 @@ public function __construct(
            */
           public function render(): View|Closure|string
           {
      +        // Store original ID for wire:model binding (property name)
      +        $this->modelBinding = $this->id;
      +
               if (is_null($this->id)) {
                   $this->id = new Cuid2;
      -        }
      -        if (is_null($this->name)) {
      -            $this->name = $this->id;
      +            // Don't create wire:model binding for auto-generated IDs
      +            $this->modelBinding = 'null';
               }
       
      -        $this->label = Str::title($this->label);
      +        // Generate unique HTML ID by adding random suffix
      +        // 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;
      +            $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
      +        } else {
      +            $this->htmlId = (string) $this->id;
      +        }
      +
      +        if (is_null($this->name)) {
      +            $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
      +        }
       
               return view('components.forms.datalist');
           }
      diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php
      index 7283ef20f..5ed347f42 100644
      --- a/app/View/Components/Forms/Input.php
      +++ b/app/View/Components/Forms/Input.php
      @@ -4,11 +4,16 @@
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
       class Input extends Component
       {
      +    public ?string $modelBinding = null;
      +
      +    public ?string $htmlId = null;
      +
           public function __construct(
               public ?string $id = null,
               public ?string $name = null,
      @@ -25,15 +30,43 @@ public function __construct(
               public string $autocomplete = 'off',
               public ?int $minlength = null,
               public ?int $maxlength = null,
      -    ) {}
      +        public bool $autofocus = false,
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
      +    ) {
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
      +    }
       
           public function render(): View|Closure|string
           {
      +        // Store original ID for wire:model binding (property name)
      +        $this->modelBinding = $this->id;
      +
               if (is_null($this->id)) {
                   $this->id = new Cuid2;
      +            // Don't create wire:model binding for auto-generated IDs
      +            $this->modelBinding = 'null';
               }
      +        // Generate unique HTML ID by adding random suffix
      +        // 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;
      +            $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
      +        } else {
      +            $this->htmlId = (string) $this->id;
      +        }
      +
               if (is_null($this->name)) {
      -            $this->name = $this->id;
      +            $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
               }
               if ($this->type === 'password') {
                   $this->defaultClass = $this->defaultClass.'  pr-[2.8rem]';
      diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php
      index feb4bf343..026e3ba8c 100644
      --- a/app/View/Components/Forms/Select.php
      +++ b/app/View/Components/Forms/Select.php
      @@ -4,11 +4,16 @@
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
       class Select extends Component
       {
      +    public ?string $modelBinding = null;
      +
      +    public ?string $htmlId = null;
      +
           /**
            * Create a new component instance.
            */
      @@ -19,9 +24,19 @@ public function __construct(
               public ?string $helper = null,
               public bool $required = false,
               public bool $disabled = false,
      -        public string $defaultClass = 'select w-full'
      +        public string $defaultClass = 'select w-full',
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      -        //
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
           }
       
           /**
      @@ -29,11 +44,27 @@ public function __construct(
            */
           public function render(): View|Closure|string
           {
      +        // Store original ID for wire:model binding (property name)
      +        $this->modelBinding = $this->id;
      +
               if (is_null($this->id)) {
                   $this->id = new Cuid2;
      +            // Don't create wire:model binding for auto-generated IDs
      +            $this->modelBinding = 'null';
               }
      +
      +        // Generate unique HTML ID by adding random suffix
      +        // 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;
      +            $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
      +        } else {
      +            $this->htmlId = (string) $this->id;
      +        }
      +
               if (is_null($this->name)) {
      -            $this->name = $this->id;
      +            $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
               }
       
               return view('components.forms.select');
      diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php
      index 6081c2a8a..a5303b947 100644
      --- a/app/View/Components/Forms/Textarea.php
      +++ b/app/View/Components/Forms/Textarea.php
      @@ -4,11 +4,16 @@
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
       class Textarea extends Component
       {
      +    public ?string $modelBinding = null;
      +
      +    public ?string $htmlId = null;
      +
           /**
            * Create a new component instance.
            */
      @@ -26,6 +31,7 @@ public function __construct(
               public bool $readonly = false,
               public bool $allowTab = false,
               public bool $spellcheck = false,
      +        public bool $autofocus = false,
               public ?string $helper = null,
               public bool $realtimeValidation = false,
               public bool $allowToPeak = true,
      @@ -33,8 +39,18 @@ public function __construct(
               public string $defaultClassInput = 'input',
               public ?int $minlength = null,
               public ?int $maxlength = null,
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      -        //
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
           }
       
           /**
      @@ -42,11 +58,27 @@ public function __construct(
            */
           public function render(): View|Closure|string
           {
      +        // Store original ID for wire:model binding (property name)
      +        $this->modelBinding = $this->id;
      +
               if (is_null($this->id)) {
                   $this->id = new Cuid2;
      +            // Don't create wire:model binding for auto-generated IDs
      +            $this->modelBinding = 'null';
               }
      +
      +        // Generate unique HTML ID by adding random suffix
      +        // 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;
      +            $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
      +        } else {
      +            $this->htmlId = (string) $this->id;
      +        }
      +
               if (is_null($this->name)) {
      -            $this->name = $this->id;
      +            $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
               }
       
               // $this->label = Str::title($this->label);
      diff --git a/app/View/Components/services/advanced.php b/app/View/Components/Services/Advanced.php
      similarity index 85%
      rename from app/View/Components/services/advanced.php
      rename to app/View/Components/Services/Advanced.php
      index 8104eaad4..99729a262 100644
      --- a/app/View/Components/services/advanced.php
      +++ b/app/View/Components/Services/Advanced.php
      @@ -1,13 +1,13 @@
       
      +- [ ] #1 Docker BuildKit cache mounts are added to Composer dependency installation in production Dockerfile
      +- [ ] #2 Docker BuildKit cache mounts are added to NPM dependency installation in production Dockerfile
      +- [ ] #3 GitHub Actions BuildX setup is configured for both AMD64 and AARCH64 jobs
      +- [ ] #4 Registry cache-from and cache-to configurations are implemented for both architecture builds
      +- [ ] #5 Build time reduction of at least 40% is achieved in staging builds
      +- [ ] #6 GitHub Actions minutes consumption is reduced compared to baseline
      +- [ ] #7 All existing build functionality remains intact with no regressions
      +
      +
      +## Implementation Plan
      +
      +1. Modify docker/production/Dockerfile to add BuildKit cache mounts:
      +   - Add cache mount for Composer dependencies at line 30: --mount=type=cache,target=/var/www/.composer/cache
      +   - Add cache mount for NPM dependencies at line 41: --mount=type=cache,target=/root/.npm
      +
      +2. Update .github/workflows/coolify-staging-build.yml for AMD64 job:
      +   - Add docker/setup-buildx-action@v3 step after checkout
      +   - Configure cache-from and cache-to parameters in build-push-action
      +   - Use registry caching with buildcache-amd64 tags
      +
      +3. Update .github/workflows/coolify-staging-build.yml for AARCH64 job:
      +   - Add docker/setup-buildx-action@v3 step after checkout  
      +   - Configure cache-from and cache-to parameters in build-push-action
      +   - Use registry caching with buildcache-aarch64 tags
      +
      +4. Test implementation:
      +   - Measure baseline build times before changes
      +   - Deploy changes and monitor initial build (will be slower due to cache population)
      +   - Measure subsequent build times to verify 40%+ improvement
      +   - Validate all build outputs and functionality remain unchanged
      +
      +5. Monitor and validate:
      +   - Track GitHub Actions minutes consumption reduction
      +   - Ensure Docker registry storage usage is reasonable
      +   - Verify no build failures or regressions introduced
      diff --git a/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md b/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md
      new file mode 100644
      index 000000000..93fa3e431
      --- /dev/null
      +++ b/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md	
      @@ -0,0 +1,24 @@
      +---
      +id: task-00001.01
      +title: Add BuildKit cache mounts to Dockerfile
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - docker
      +  - buildkit
      +  - performance
      +  - dockerfile
      +dependencies: []
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Modify the production Dockerfile to include BuildKit cache mounts for Composer and NPM dependencies to speed up subsequent builds by reusing cached dependency installations
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Cache mount for Composer dependencies is added at line 30 with --mount=type=cache target=/var/www/.composer/cache,Cache mount for NPM dependencies is added at line 41 with --mount=type=cache target=/root/.npm,Dockerfile syntax remains valid and builds successfully,All existing functionality is preserved with no regressions
      +
      diff --git a/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md b/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md
      new file mode 100644
      index 000000000..60ac514f6
      --- /dev/null
      +++ b/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md	
      @@ -0,0 +1,24 @@
      +---
      +id: task-00001.02
      +title: Configure BuildX and registry caching for AMD64 staging builds
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - github-actions
      +  - buildx
      +  - caching
      +  - amd64
      +dependencies: []
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AMD64 build job to leverage Docker layer caching across builds
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AMD64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-amd64 naming convention for architecture-specific caching,Build job runs successfully with caching enabled,No impact on existing build outputs or functionality
      +
      diff --git a/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md b/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md
      new file mode 100644
      index 000000000..3dd730d34
      --- /dev/null
      +++ b/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md	
      @@ -0,0 +1,25 @@
      +---
      +id: task-00001.03
      +title: Configure BuildX and registry caching for AARCH64 staging builds
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - github-actions
      +  - buildx
      +  - caching
      +  - aarch64
      +  - self-hosted
      +dependencies: []
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AARCH64 build job running on self-hosted ARM64 runners
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AARCH64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-aarch64 naming convention for architecture-specific caching,Build job runs successfully on self-hosted ARM64 runner with caching enabled,No impact on existing build outputs or functionality
      +
      diff --git a/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md b/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md
      new file mode 100644
      index 000000000..6fa997663
      --- /dev/null
      +++ b/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md	
      @@ -0,0 +1,24 @@
      +---
      +id: task-00001.04
      +title: Establish build time baseline measurements
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - performance
      +  - testing
      +  - baseline
      +  - measurement
      +dependencies: []
      +parent_task_id: task-00001
      +priority: medium
      +---
      +
      +## Description
      +
      +Measure and document current staging build times for both AMD64 and AARCH64 architectures before implementing caching optimizations to establish a performance baseline for comparison
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Baseline build times are measured for at least 3 consecutive AMD64 builds,Baseline build times are measured for at least 3 consecutive AARCH64 builds,Average build time and GitHub Actions minutes consumption are documented,Baseline measurements include both cold builds and any existing warm builds,Results are documented in a format suitable for comparing against post-optimization builds
      +
      diff --git a/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md b/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md
      new file mode 100644
      index 000000000..6a11168da
      --- /dev/null
      +++ b/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md	
      @@ -0,0 +1,28 @@
      +---
      +id: task-00001.05
      +title: Validate caching implementation and measure performance improvements
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - testing
      +  - performance
      +  - validation
      +  - measurement
      +dependencies:
      +  - task-00001.01
      +  - task-00001.02
      +  - task-00001.03
      +  - task-00001.04
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Test the complete Docker build caching implementation by running multiple staging builds and measuring performance improvements to ensure the 40% build time reduction target is achieved
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 First build after cache implementation runs successfully (expected slower due to cache population),Second and subsequent builds show significant time reduction compared to baseline,Build time reduction of at least 40% is achieved and documented,GitHub Actions minutes consumption is reduced compared to baseline measurements,All Docker images function identically to pre-optimization builds,No build failures or regressions are introduced by caching changes
      +
      diff --git a/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md b/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md
      new file mode 100644
      index 000000000..3749e58f3
      --- /dev/null
      +++ b/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md	
      @@ -0,0 +1,25 @@
      +---
      +id: task-00001.06
      +title: Document cache optimization results and create production workflow plan
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - documentation
      +  - planning
      +  - production
      +  - analysis
      +dependencies:
      +  - task-00001.05
      +parent_task_id: task-00001
      +priority: low
      +---
      +
      +## Description
      +
      +Document the staging build caching results and create a detailed plan for applying the same optimizations to the production build workflow if staging results meet performance targets
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Performance improvement results are documented with before/after metrics,Cost savings in GitHub Actions minutes are calculated and documented,Analysis of Docker registry storage impact is provided,Detailed plan for production workflow optimization is created,Recommendations for cache retention policies and cleanup strategies are provided,Documentation includes rollback procedures if issues arise
      +
      diff --git a/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md b/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md
      new file mode 100644
      index 000000000..d0e63456b
      --- /dev/null
      +++ b/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md	
      @@ -0,0 +1,82 @@
      +---
      +id: task-00002
      +title: Fix Docker cleanup irregular scheduling in cloud environment
      +status: Done
      +assignee:
      +  - '@claude'
      +created_date: '2025-08-26 12:17'
      +updated_date: '2025-08-26 12:26'
      +labels:
      +  - backend
      +  - performance
      +  - cloud
      +dependencies: []
      +priority: high
      +---
      +
      +## Description
      +
      +Docker cleanup jobs are running at irregular intervals instead of hourly as configured (0 * * * *) in the cloud environment with 2 Horizon workers and thousands of servers. The issue stems from the ServerManagerJob processing servers sequentially with a frozen execution time, causing timing mismatches when evaluating cron expressions for large server counts.
      +
      +## Acceptance Criteria
      +
      +- [x] #1 Docker cleanup runs consistently at the configured hourly intervals
      +- [x] #2 All eligible servers receive cleanup jobs when due
      +- [x] #3 Solution handles thousands of servers efficiently
      +- [x] #4 Maintains backwards compatibility with existing settings
      +- [x] #5 Cloud subscription checks are properly enforced
      +
      +
      +## Implementation Plan
      +
      +1. Add processDockerCleanups() method to ScheduledJobManager
      +   - Implement method to fetch all eligible servers
      +   - Apply frozen execution time for consistent cron evaluation
      +   - Check server functionality and cloud subscription status
      +   - Dispatch DockerCleanupJob for servers where cleanup is due
      +
      +2. Implement helper methods in ScheduledJobManager
      +   - getServersForCleanup(): Fetch servers with proper cloud/self-hosted filtering
      +   - shouldProcessDockerCleanup(): Validate server eligibility
      +   - Reuse existing shouldRunNow() method with frozen execution time
      +
      +3. Remove Docker cleanup logic from ServerManagerJob
      +   - Delete lines 136-150 that handle Docker cleanup scheduling
      +   - Keep other server management tasks intact
      +
      +4. Test the implementation
      +   - Verify hourly execution with test servers
      +   - Check timezone handling
      +   - Validate cloud subscription filtering
      +   - Monitor for duplicate job prevention via WithoutOverlapping middleware
      +
      +5. Deploy strategy
      +   - First deploy updated ScheduledJobManager
      +   - Monitor logs for successful hourly executions
      +   - Once confirmed, remove cleanup from ServerManagerJob
      +   - No database migrations required
      +
      +## Implementation Notes
      +
      +Successfully migrated Docker cleanup scheduling from ServerManagerJob to ScheduledJobManager.
      +
      +**Changes Made:**
      +1. Added processDockerCleanups() method to ScheduledJobManager that processes all servers with a single frozen execution time
      +2. Implemented getServersForCleanup() to fetch servers with proper cloud/self-hosted filtering
      +3. Implemented shouldProcessDockerCleanup() for server eligibility validation
      +4. Removed Docker cleanup logic from ServerManagerJob (lines 136-150)
      +
      +**Key Improvements:**
      +- All servers now evaluated against the same timestamp, ensuring consistent hourly execution
      +- Proper cloud subscription checks maintained
      +- Backwards compatible - no database migrations or settings changes required
      +- Follows the same proven pattern used for database backups
      +
      +**Files Modified:**
      +- app/Jobs/ScheduledJobManager.php: Added Docker cleanup processing
      +- app/Jobs/ServerManagerJob.php: Removed Docker cleanup logic
      +
      +**Testing:**
      +- Syntax validation passed
      +- Code formatting verified with Laravel Pint
      +- PHPStan analysis completed (existing warnings unrelated to changes)
      diff --git a/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md b/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md
      new file mode 100644
      index 000000000..38aa18209
      --- /dev/null
      +++ b/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md	
      @@ -0,0 +1,30 @@
      +---
      +id: task-00003
      +title: Simplify resource operations UI - replace boxes with dropdown selections
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 13:22'
      +updated_date: '2025-08-26 13:22'
      +labels:
      +  - ui
      +  - frontend
      +  - livewire
      +dependencies: []
      +priority: medium
      +---
      +
      +## Description
      +
      +Replace the current box-based layout in resource-operations.blade.php with clean dropdown selections to improve UX when there are many servers, projects, or environments. The current interface becomes overwhelming and cluttered with multiple modal confirmation boxes for each option.
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Clone section shows a dropdown to select server/destination instead of multiple boxes
      +- [ ] #2 Move section shows a dropdown to select project/environment instead of multiple boxes
      +- [ ] #3 Single "Clone Resource" button that triggers modal after dropdown selection
      +- [ ] #4 Single "Move Resource" button that triggers modal after dropdown selection
      +- [ ] #5 Authorization warnings remain in place for users without permissions
      +- [ ] #6 All existing functionality preserved (cloning, moving, success messages)
      +- [ ] #7 Clean, simple interface that scales well with many options
      +- [ ] #8 Mobile-friendly dropdown interface
      +
      diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
      index 307c7ed1b..5d0f9a2a7 100644
      --- a/bootstrap/helpers/api.php
      +++ b/bootstrap/helpers/api.php
      @@ -176,4 +176,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
           $request->offsetUnset('private_key_uuid');
           $request->offsetUnset('use_build_server');
           $request->offsetUnset('is_static');
      +    $request->offsetUnset('force_domain_override');
       }
      diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
      index 919b2bde5..db7767c1e 100644
      --- a/bootstrap/helpers/applications.php
      +++ b/bootstrap/helpers/applications.php
      @@ -1,12 +1,15 @@
       id,
               );
      -    } elseif (next_queuable($server_id, $application_id, $commit)) {
      +    } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) {
               ApplicationDeploymentJob::dispatch(
                   application_deployment_queue_id: $deployment->id,
               );
      @@ -93,32 +96,31 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment)
       function queue_next_deployment(Application $application)
       {
           $server_id = $application->destination->server_id;
      -    $next_found = ApplicationDeploymentQueue::where('server_id', $server_id)->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at')->first();
      -    if ($next_found) {
      -        $next_found->update([
      -            'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
      -        ]);
      +    $queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id)
      +        ->where('status', ApplicationDeploymentStatus::QUEUED)
      +        ->get()
      +        ->sortBy('created_at');
       
      -        ApplicationDeploymentJob::dispatch(
      -            application_deployment_queue_id: $next_found->id,
      -        );
      +    foreach ($queued_deployments as $next_deployment) {
      +        // Check if this queued deployment can actually run
      +        if (next_queuable($next_deployment->server_id, $next_deployment->application_id, $next_deployment->commit, $next_deployment->pull_request_id)) {
      +            $next_deployment->update([
      +                'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
      +            ]);
      +
      +            ApplicationDeploymentJob::dispatch(
      +                application_deployment_queue_id: $next_deployment->id,
      +            );
      +        }
           }
       }
       
      -function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD'): bool
      +function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD', int $pull_request_id = 0): bool
       {
      -    // Check if there's already a deployment in progress for this application and commit
      -    $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
      -        ->where('commit', $commit)
      -        ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
      -        ->first();
      -
      -    if ($existing_deployment) {
      -        return false;
      -    }
      -
      -    // Check if there's any deployment in progress for this application
      +    // Check if there's already a deployment in progress for this application with the same pull_request_id
      +    // This allows normal deployments and PR deployments to run concurrently
           $in_progress = ApplicationDeploymentQueue::where('application_id', $application_id)
      +        ->where('pull_request_id', $pull_request_id)
               ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
               ->exists();
       
      @@ -142,13 +144,15 @@ function next_queuable(string $server_id, string $application_id, string $commit
       function next_after_cancel(?Server $server = null)
       {
           if ($server) {
      -        $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at');
      +        $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))
      +            ->where('status', ApplicationDeploymentStatus::QUEUED)
      +            ->get()
      +            ->sortBy('created_at');
      +
               if ($next_found->count() > 0) {
                   foreach ($next_found as $next) {
      -                $server = Server::find($next->server_id);
      -                $concurrent_builds = $server->settings->concurrent_builds;
      -                $inprogress_deployments = ApplicationDeploymentQueue::where('server_id', $next->server_id)->whereIn('status', [ApplicationDeploymentStatus::QUEUED])->get()->sortByDesc('created_at');
      -                if ($inprogress_deployments->count() < $concurrent_builds) {
      +                // Use next_queuable to properly check if this deployment can run
      +                if (next_queuable($next->server_id, $next->application_id, $next->commit, $next->pull_request_id)) {
                           $next->update([
                               'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
                           ]);
      @@ -157,8 +161,195 @@ function next_after_cancel(?Server $server = null)
                               application_deployment_queue_id: $next->id,
                           );
                       }
      -                break;
                   }
               }
           }
       }
      +
      +function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
      +{
      +    $uuid = $overrides['uuid'] ?? (string) new Cuid2;
      +    $server = $destination->server;
      +
      +    // Prepare name and URL
      +    $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
      +    $applicationSettings = $source->settings;
      +    $url = $overrides['fqdn'] ?? $source->fqdn;
      +
      +    if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
      +        $url = generateUrl(server: $server, random: $uuid);
      +    }
      +
      +    // Clone the application
      +    $newApplication = $source->replicate([
      +        'id',
      +        'created_at',
      +        'updated_at',
      +        'additional_servers_count',
      +        'additional_networks_count',
      +    ])->fill(array_merge([
      +        'uuid' => $uuid,
      +        'name' => $name,
      +        'fqdn' => $url,
      +        'status' => 'exited',
      +        'destination_id' => $destination->id,
      +    ], $overrides));
      +    $newApplication->save();
      +
      +    // Update custom labels if needed
      +    if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
      +        $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
      +        $newApplication->custom_labels = base64_encode($customLabels);
      +        $newApplication->save();
      +    }
      +
      +    // Clone settings
      +    $newApplication->settings()->delete();
      +    if ($applicationSettings) {
      +        $newApplicationSettings = $applicationSettings->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'application_id' => $newApplication->id,
      +        ]);
      +        $newApplicationSettings->save();
      +    }
      +
      +    // Clone tags
      +    $tags = $source->tags;
      +    foreach ($tags as $tag) {
      +        $newApplication->tags()->attach($tag->id);
      +    }
      +
      +    // Clone scheduled tasks
      +    $scheduledTasks = $source->scheduled_tasks()->get();
      +    foreach ($scheduledTasks as $task) {
      +        $newTask = $task->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'uuid' => (string) new Cuid2,
      +            'application_id' => $newApplication->id,
      +            'team_id' => currentTeam()->id,
      +        ]);
      +        $newTask->save();
      +    }
      +
      +    // Clone previews with FQDN regeneration
      +    $applicationPreviews = $source->previews()->get();
      +    foreach ($applicationPreviews as $preview) {
      +        $newPreview = $preview->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'uuid' => (string) new Cuid2,
      +            'application_id' => $newApplication->id,
      +            'status' => 'exited',
      +            'fqdn' => null,
      +            'docker_compose_domains' => null,
      +        ]);
      +        $newPreview->save();
      +
      +        // Regenerate FQDN for the cloned preview
      +        if ($newApplication->build_pack === 'dockercompose') {
      +            $newPreview->generate_preview_fqdn_compose();
      +        } else {
      +            $newPreview->generate_preview_fqdn();
      +        }
      +    }
      +
      +    // Clone persistent volumes
      +    $persistentVolumes = $source->persistentStorages()->get();
      +    foreach ($persistentVolumes as $volume) {
      +        $newName = '';
      +        if (str_starts_with($volume->name, $source->uuid)) {
      +            $newName = str($volume->name)->replace($source->uuid, $newApplication->uuid);
      +        } else {
      +            $newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-');
      +        }
      +
      +        $newPersistentVolume = $volume->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'name' => $newName,
      +            'resource_id' => $newApplication->id,
      +        ]);
      +        $newPersistentVolume->save();
      +
      +        if ($cloneVolumeData) {
      +            try {
      +                StopApplication::dispatch($source, false, false);
      +                $sourceVolume = $volume->name;
      +                $targetVolume = $newPersistentVolume->name;
      +                $sourceServer = $source->destination->server;
      +                $targetServer = $newApplication->destination->server;
      +
      +                VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
      +
      +                queue_application_deployment(
      +                    deployment_uuid: (string) new Cuid2,
      +                    application: $source,
      +                    server: $sourceServer,
      +                    destination: $source->destination,
      +                    no_questions_asked: true
      +                );
      +            } catch (\Exception $e) {
      +                \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
      +            }
      +        }
      +    }
      +
      +    // Clone file storages
      +    $fileStorages = $source->fileStorages()->get();
      +    foreach ($fileStorages as $storage) {
      +        $newStorage = $storage->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'resource_id' => $newApplication->id,
      +        ]);
      +        $newStorage->save();
      +    }
      +
      +    // Clone production environment variables without triggering the created hook
      +    $environmentVariables = $source->environment_variables()->get();
      +    foreach ($environmentVariables as $environmentVariable) {
      +        \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
      +            $newEnvironmentVariable = $environmentVariable->replicate([
      +                'id',
      +                'created_at',
      +                'updated_at',
      +            ])->fill([
      +                'resourceable_id' => $newApplication->id,
      +                'resourceable_type' => $newApplication->getMorphClass(),
      +                'is_preview' => false,
      +            ]);
      +            $newEnvironmentVariable->save();
      +        });
      +    }
      +
      +    // Clone preview environment variables
      +    $previewEnvironmentVariables = $source->environment_variables_preview()->get();
      +    foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
      +        \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
      +            $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
      +                'id',
      +                'created_at',
      +                'updated_at',
      +            ])->fill([
      +                'resourceable_id' => $newApplication->id,
      +                'resourceable_type' => $newApplication->getMorphClass(),
      +                'is_preview' => true,
      +            ]);
      +            $newPreviewEnvironmentVariable->save();
      +        });
      +    }
      +
      +    return $newApplication;
      +}
      diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
      index b568e090c..36243e119 100644
      --- a/bootstrap/helpers/constants.php
      +++ b/bootstrap/helpers/constants.php
      @@ -21,13 +21,23 @@
           'bitnami/mariadb',
           'bitnami/mongodb',
           'bitnami/redis',
      +    'bitnamilegacy/mariadb',
      +    'bitnamilegacy/mongodb',
      +    'bitnamilegacy/redis',
      +    'bitnamisecure/mariadb',
      +    'bitnamisecure/mongodb',
      +    'bitnamisecure/redis',
           'mysql',
           'bitnami/mysql',
      +    'bitnamilegacy/mysql',
      +    'bitnamisecure/mysql',
           'mysql/mysql-server',
           'mariadb',
           'postgis/postgis',
           'postgres',
           'bitnami/postgresql',
      +    'bitnamilegacy/postgresql',
      +    'bitnamisecure/postgresql',
           'supabase/postgres',
           'elestio/postgres',
           'mongo',
      diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
      index 48962f89c..aa7be3236 100644
      --- a/bootstrap/helpers/databases.php
      +++ b/bootstrap/helpers/databases.php
      @@ -237,11 +237,17 @@ function removeOldBackups($backup): void
       {
           try {
               if ($backup->executions) {
      -            $localBackupsToDelete = deleteOldBackupsLocally($backup);
      -            if ($localBackupsToDelete->isNotEmpty()) {
      -                $backup->executions()
      -                    ->whereIn('id', $localBackupsToDelete->pluck('id'))
      -                    ->update(['local_storage_deleted' => true]);
      +            // Delete old local backups (only if local backup is NOT disabled)
      +            // Note: When disable_local_backup is enabled, each execution already marks its own
      +            // local_storage_deleted status at the time of backup, so we don't need to retroactively
      +            // update old executions
      +            if (! $backup->disable_local_backup) {
      +                $localBackupsToDelete = deleteOldBackupsLocally($backup);
      +                if ($localBackupsToDelete->isNotEmpty()) {
      +                    $backup->executions()
      +                        ->whereIn('id', $localBackupsToDelete->pluck('id'))
      +                        ->update(['local_storage_deleted' => true]);
      +                }
                   }
               }
       
      @@ -254,11 +260,19 @@ function removeOldBackups($backup): void
                   }
               }
       
      +        // Delete execution records where all backup copies are gone
      +        // Case 1: Both local and S3 backups are deleted
               $backup->executions()
                   ->where('local_storage_deleted', true)
                   ->where('s3_storage_deleted', true)
                   ->delete();
       
      +        // Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
      +        $backup->executions()
      +            ->where('local_storage_deleted', true)
      +            ->whereNull('s3_uploaded')
      +            ->delete();
      +
           } catch (\Exception $e) {
               throw $e;
           }
      diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
      index fc6dad25f..d6c9b5bdf 100644
      --- a/bootstrap/helpers/docker.php
      +++ b/bootstrap/helpers/docker.php
      @@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
       
                   if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
                       $MINIO_BROWSER_REDIRECT_URL->update([
      -                    'value' => generateFqdn($server, 'console-'.$uuid, true),
      +                    'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true),
                       ]);
                   }
                   if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
                       $MINIO_SERVER_URL->update([
      -                    'value' => generateFqdn($server, 'minio-'.$uuid, true),
      +                    'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true),
                       ]);
                   }
                   $payload = collect([
      @@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
       
                   if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
                       $LOGTO_ENDPOINT->update([
      -                    'value' => generateFqdn($server, 'logto-'.$uuid),
      +                    'value' => generateUrl(server: $server, random: 'logto-'.$uuid),
                       ]);
                   }
                   if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
                       $LOGTO_ADMIN_ENDPOINT->update([
      -                    'value' => generateFqdn($server, 'logto-admin-'.$uuid),
      +                    'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid),
                       ]);
                   }
                   $payload = collect([
      @@ -359,7 +359,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
       {
           $labels = collect([]);
           $labels->push('traefik.enable=true');
      -    $labels->push('traefik.http.middlewares.gzip.compress=true');
      +    if ($is_gzip_enabled) {
      +        $labels->push('traefik.http.middlewares.gzip.compress=true');
      +    }
           $labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https');
       
           $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
      @@ -376,6 +378,16 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
       
           if ($serviceLabels) {
               $middlewares_from_labels = $serviceLabels->map(function ($item) {
      +            // Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)
      +            if (is_array($item)) {
      +                // Convert array to string format "key=value"
      +                $key = collect($item)->keys()->first();
      +                $value = collect($item)->values()->first();
      +                $item = "$key=$value";
      +            }
      +            if (! is_string($item)) {
      +                return null;
      +            }
                   if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) {
                       return $matches[1];
                   }
      @@ -724,11 +736,12 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
           return $labels->all();
       }
       
      -function isDatabaseImage(?string $image = null)
      +function isDatabaseImage(?string $image = null, ?array $serviceConfig = null)
       {
           if (is_null($image)) {
               return false;
           }
      +
           $image = str($image);
           if ($image->contains(':')) {
               $image = str($image);
      @@ -736,13 +749,170 @@ function isDatabaseImage(?string $image = null)
               $image = str($image)->append(':latest');
           }
           $imageName = $image->before(':');
      +
      +    // First check if it's a known database image
      +    $isKnownDatabase = false;
           foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) {
               if (str($imageName)->contains($database_docker_image)) {
      -            return true;
      +            $isKnownDatabase = true;
      +            break;
               }
           }
       
      -    return false;
      +    // If no database pattern found, it's definitely not a database
      +    if (! $isKnownDatabase) {
      +        return false;
      +    }
      +
      +    // If we have service configuration, use additional context to make better decisions
      +    if (! is_null($serviceConfig)) {
      +        return isDatabaseImageWithContext($imageName, $serviceConfig);
      +    }
      +
      +    // Fallback to original behavior for backward compatibility
      +    return $isKnownDatabase;
      +}
      +
      +function isDatabaseImageWithContext(string $imageName, array $serviceConfig): bool
      +{
      +    // Known application images that contain database names but are not databases
      +    $knownApplicationPatterns = [
      +        // SuperTokens authentication
      +        'supertokens/supertokens-mysql',
      +        'supertokens/supertokens-postgresql',
      +        'supertokens/supertokens-mongodb',
      +        'registry.supertokens.io/supertokens/supertokens-mysql',
      +        'registry.supertokens.io/supertokens/supertokens-postgresql',
      +        'registry.supertokens.io/supertokens/supertokens-mongodb',
      +        'registry.supertokens.io/supertokens',
      +
      +        // Analytics and BI tools
      +        'metabase/metabase', // Uses databases but is not a database
      +        'amancevice/superset', // Uses databases but is not a database
      +        'nocodb/nocodb', // Uses databases but is not a database
      +        'ghcr.io/umami-software/umami', // Web analytics with postgresql variant
      +
      +        // Secret management
      +        'infisical/infisical', // Secret management with postgres variant
      +
      +        // Development tools
      +        'postgrest/postgrest', // REST API for PostgreSQL
      +        'supabase/postgres-meta', // PostgreSQL metadata API
      +        'bluewaveuptime/uptime_redis', // Uptime monitoring with Redis
      +    ];
      +
      +    foreach ($knownApplicationPatterns as $pattern) {
      +        if (str($imageName)->contains($pattern)) {
      +            return false;
      +        }
      +    }
      +
      +    // Check for database-like ports (common database ports indicate it's likely a database)
      +    $databasePorts = ['3306', '5432', '27017', '6379', '8086', '9200', '7687', '8123'];
      +    $ports = data_get($serviceConfig, 'ports', []);
      +    $hasStandardDbPort = false;
      +
      +    if (is_array($ports)) {
      +        foreach ($ports as $port) {
      +            $portStr = is_string($port) ? $port : (string) $port;
      +            foreach ($databasePorts as $dbPort) {
      +                if (str($portStr)->contains($dbPort)) {
      +                    $hasStandardDbPort = true;
      +                    break 2;
      +                }
      +            }
      +        }
      +    }
      +
      +    // Check environment variables for database-specific patterns
      +    $environment = data_get($serviceConfig, 'environment', []);
      +    $hasDbEnvVars = false;
      +    $hasAppEnvVars = false;
      +
      +    if (is_array($environment)) {
      +        foreach ($environment as $env) {
      +            $envStr = is_string($env) ? $env : (string) $env;
      +            $envUpper = strtoupper($envStr);
      +
      +            // Database-specific environment variables
      +            if (str($envUpper)->contains(['MYSQL_ROOT_PASSWORD', 'POSTGRES_PASSWORD', 'MONGO_INITDB_ROOT_PASSWORD', 'REDIS_PASSWORD'])) {
      +                $hasDbEnvVars = true;
      +            }
      +
      +            // Application-specific environment variables
      +            if (str($envUpper)->contains(['SERVICE_FQDN', 'API_KEYS', 'APP_', 'APPLICATION_'])) {
      +                $hasAppEnvVars = true;
      +            }
      +        }
      +    }
      +
      +    // Check healthcheck patterns
      +    $healthcheck = data_get($serviceConfig, 'healthcheck.test', []);
      +    $hasDbHealthcheck = false;
      +    $hasAppHealthcheck = false;
      +
      +    if (is_array($healthcheck)) {
      +        $healthcheckStr = implode(' ', $healthcheck);
      +    } else {
      +        $healthcheckStr = is_string($healthcheck) ? $healthcheck : '';
      +    }
      +
      +    if (! empty($healthcheckStr)) {
      +        $healthcheckUpper = strtoupper($healthcheckStr);
      +
      +        // Database-specific healthcheck patterns
      +        if (str($healthcheckUpper)->contains(['PG_ISREADY', 'MYSQLADMIN PING', 'MONGO', 'REDIS-CLI PING'])) {
      +            $hasDbHealthcheck = true;
      +        }
      +
      +        // Application-specific healthcheck patterns (HTTP endpoints)
      +        if (str($healthcheckUpper)->contains(['CURL', 'WGET', 'HTTP://', 'HTTPS://', '/HEALTH', '/API/', '/HELLO'])) {
      +            $hasAppHealthcheck = true;
      +        }
      +    }
      +
      +    // Check if service depends on other database services
      +    $dependsOn = data_get($serviceConfig, 'depends_on', []);
      +    $dependsOnDatabases = false;
      +
      +    if (is_array($dependsOn)) {
      +        foreach ($dependsOn as $serviceName => $config) {
      +            $serviceNameStr = is_string($serviceName) ? $serviceName : (string) $serviceName;
      +            if (str($serviceNameStr)->contains(['mysql', 'postgres', 'mongo', 'redis', 'mariadb'])) {
      +                $dependsOnDatabases = true;
      +                break;
      +            }
      +        }
      +    }
      +
      +    // Decision logic:
      +    // 1. If it has app-specific patterns and depends on databases, it's likely an application
      +    if ($hasAppEnvVars && $dependsOnDatabases) {
      +        return false;
      +    }
      +
      +    // 2. If it has HTTP healthchecks, it's likely an application
      +    if ($hasAppHealthcheck) {
      +        return false;
      +    }
      +
      +    // 3. If it has standard database ports AND database healthchecks, it's likely a database
      +    if ($hasStandardDbPort && $hasDbHealthcheck) {
      +        return true;
      +    }
      +
      +    // 4. If it has database environment variables, it's likely a database
      +    if ($hasDbEnvVars) {
      +        return true;
      +    }
      +
      +    // 5. Default: if it depends on databases but doesn't have database characteristics, it's an application
      +    if ($dependsOnDatabases) {
      +        return false;
      +    }
      +
      +    // 6. Fallback: assume it's a database if we can't determine otherwise
      +    return true;
       }
       
       function convertDockerRunToCompose(?string $custom_docker_run_options = null)
      @@ -933,19 +1103,18 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
       {
           if ($server->isSwarm()) {
               $output = instant_remote_process([
      -            "docker service logs -n {$lines} {$container_id}",
      +            "docker service logs -n {$lines} {$container_id} 2>&1",
               ], $server);
           } else {
               $output = instant_remote_process([
      -            "docker logs -n {$lines} {$container_id}",
      +            "docker logs -n {$lines} {$container_id} 2>&1",
               ], $server);
           }
       
      -    $output .= removeAnsiColors($output);
      +    $output = removeAnsiColors($output);
       
           return $output;
       }
      -
       function escapeEnvVariables($value)
       {
           $search = ['\\', "\r", "\t", "\x0", '"', "'"];
      @@ -960,3 +1129,123 @@ function escapeDollarSign($value)
       
           return str_replace($search, $replace, $value);
       }
      +
      +/**
      + * Escape a value for use in a bash .env file that will be sourced with 'source' command
      + * Wraps the value in single quotes and escapes any single quotes within the value
      + *
      + * @param  string|null  $value  The value to escape
      + * @return string The escaped value wrapped in single quotes
      + */
      +function escapeBashEnvValue(?string $value): string
      +{
      +    // Handle null or empty values
      +    if ($value === null || $value === '') {
      +        return "''";
      +    }
      +
      +    // Replace single quotes with '\'' (end quote, escaped quote, start quote)
      +    // This is the standard way to escape single quotes in bash single-quoted strings
      +    $escaped = str_replace("'", "'\\''", $value);
      +
      +    // Wrap in single quotes
      +    return "'{$escaped}'";
      +}
      +
      +/**
      + * Escape a value for bash double-quoted strings (allows $VAR expansion)
      + *
      + * This function wraps values in double quotes while escaping special characters,
      + * but preserves valid bash variable references like $VAR and ${VAR}.
      + *
      + * @param  string|null  $value  The value to escape
      + * @return string The escaped value wrapped in double quotes
      + */
      +function escapeBashDoubleQuoted(?string $value): string
      +{
      +    // Handle null or empty values
      +    if ($value === null || $value === '') {
      +        return '""';
      +    }
      +
      +    // Step 1: Escape backslashes first (must be done before other escaping)
      +    $escaped = str_replace('\\', '\\\\', $value);
      +
      +    // Step 2: Escape double quotes
      +    $escaped = str_replace('"', '\\"', $escaped);
      +
      +    // Step 3: Escape backticks (command substitution)
      +    $escaped = str_replace('`', '\\`', $escaped);
      +
      +    // Step 4: Escape invalid $ patterns while preserving valid variable references
      +    // Valid patterns to keep:
      +    //   - $VAR_NAME (alphanumeric + underscore, starting with letter or _)
      +    //   - ${VAR_NAME} (brace expansion)
      +    //   - $0-$9 (positional parameters)
      +    // Invalid patterns to escape: $&, $#, $$, $*, $@, $!, $(, etc.
      +
      +    // Match $ followed by anything that's NOT a valid variable start
      +    // Valid variable starts: letter, underscore, digit (for $0-$9), or open brace
      +    $escaped = preg_replace(
      +        '/\$(?![a-zA-Z_0-9{])/',
      +        '\\\$',
      +        $escaped
      +    );
      +
      +    // Preserve pre-escaped dollars inside double quotes: turn \\$ back into \$
      +    // (keeps tests like "path\\to\\file" intact while restoring \$ semantics)
      +    $escaped = preg_replace('/\\\\(?=\$)/', '\\\\', $escaped);
      +
      +    // Wrap in double quotes
      +    return "\"{$escaped}\"";
      +}
      +
      +/**
      + * Generate Docker build arguments from environment variables collection
      + * Returns only keys (no values) since values are sourced from environment via export
      + *
      + * @param  \Illuminate\Support\Collection|array  $variables  Collection of variables with 'key', 'value', and optionally 'is_multiline'
      + * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
      + */
      +function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
      +{
      +    $variables = collect($variables);
      +
      +    return $variables->map(function ($var) {
      +        $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}";
      +    });
      +}
      +
      +/**
      + * Generate Docker environment flags from environment variables collection
      + *
      + * @param  \Illuminate\Support\Collection|array  $variables  Collection of variables with 'key', 'value', and optionally 'is_multiline'
      + * @return string Space-separated environment flags
      + */
      +function generateDockerEnvFlags($variables): string
      +{
      +    $variables = collect($variables);
      +
      +    return $variables
      +        ->map(function ($var) {
      +            $key = is_array($var) ? data_get($var, 'key') : $var->key;
      +            $value = is_array($var) ? data_get($var, 'value') : $var->value;
      +            $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false);
      +
      +            if ($isMultiline) {
      +                // For multiline variables, strip surrounding quotes and escape for bash
      +                $raw_value = trim($value, "'");
      +                $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value);
      +
      +                return "-e {$key}=\"{$escaped_value}\"";
      +            }
      +
      +            $escaped_value = escapeshellarg($value);
      +
      +            return "-e {$key}={$escaped_value}";
      +        })
      +        ->implode(' ');
      +}
      diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php
      new file mode 100644
      index 000000000..5b665890c
      --- /dev/null
      +++ b/bootstrap/helpers/domains.php
      @@ -0,0 +1,237 @@
      +team();
      +    }
      +
      +    if ($resource) {
      +        if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') {
      +            $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
      +            $domains = collect($domains);
      +        } else {
      +            $domains = collect($resource->fqdns);
      +        }
      +    } elseif ($domain) {
      +        $domains = collect([$domain]);
      +    } else {
      +        return ['conflicts' => [], 'hasConflicts' => false];
      +    }
      +
      +    $domains = $domains->map(function ($domain) {
      +        if (str($domain)->endsWith('/')) {
      +            $domain = str($domain)->beforeLast('/');
      +        }
      +
      +        return str($domain);
      +    });
      +
      +    // Filter applications by team if we have a current team
      +    $appsQuery = Application::query();
      +    if ($currentTeam) {
      +        $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) {
      +            $query->where('team_id', $currentTeam->id);
      +        });
      +    }
      +    $apps = $appsQuery->get();
      +    foreach ($apps as $app) {
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                if (data_get($resource, 'uuid')) {
      +                    if ($resource->uuid !== $app->uuid) {
      +                        $conflicts[] = [
      +                            'domain' => $naked_domain,
      +                            'resource_name' => $app->name,
      +                            'resource_link' => $app->link(),
      +                            'resource_type' => 'application',
      +                            'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
      +                        ];
      +                    }
      +                } elseif ($domain) {
      +                    $conflicts[] = [
      +                        'domain' => $naked_domain,
      +                        'resource_name' => $app->name,
      +                        'resource_link' => $app->link(),
      +                        'resource_type' => 'application',
      +                        'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
      +                    ];
      +                }
      +            }
      +        }
      +    }
      +
      +    // Filter service applications by team if we have a current team
      +    $serviceAppsQuery = ServiceApplication::query();
      +    if ($currentTeam) {
      +        $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) {
      +            $query->where('team_id', $currentTeam->id);
      +        });
      +    }
      +    $apps = $serviceAppsQuery->get();
      +    foreach ($apps as $app) {
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                if (data_get($resource, 'uuid')) {
      +                    if ($resource->uuid !== $app->uuid) {
      +                        $conflicts[] = [
      +                            'domain' => $naked_domain,
      +                            'resource_name' => $app->service->name,
      +                            'resource_link' => $app->service->link(),
      +                            'resource_type' => 'service',
      +                            'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
      +                        ];
      +                    }
      +                } elseif ($domain) {
      +                    $conflicts[] = [
      +                        'domain' => $naked_domain,
      +                        'resource_name' => $app->service->name,
      +                        'resource_link' => $app->service->link(),
      +                        'resource_type' => 'service',
      +                        'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
      +                    ];
      +                }
      +            }
      +        }
      +    }
      +
      +    if ($resource) {
      +        $settings = instanceSettings();
      +        if (data_get($settings, 'fqdn')) {
      +            $domain = data_get($settings, 'fqdn');
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                $conflicts[] = [
      +                    'domain' => $naked_domain,
      +                    'resource_name' => 'Coolify Instance',
      +                    'resource_link' => '#',
      +                    'resource_type' => 'instance',
      +                    'message' => "Domain $naked_domain is already in use by this Coolify instance",
      +                ];
      +            }
      +        }
      +    }
      +
      +    return [
      +        'conflicts' => $conflicts,
      +        'hasConflicts' => count($conflicts) > 0,
      +    ];
      +}
      +
      +function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
      +{
      +    $conflicts = [];
      +
      +    if (is_null($teamId)) {
      +        return ['error' => 'Team ID is required.'];
      +    }
      +    if (is_array($domains)) {
      +        $domains = collect($domains);
      +    }
      +
      +    $domains = $domains->map(function ($domain) {
      +        if (str($domain)->endsWith('/')) {
      +            $domain = str($domain)->beforeLast('/');
      +        }
      +
      +        return str($domain);
      +    });
      +
      +    // Check applications within the same team
      +    $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id']);
      +    $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']);
      +
      +    if ($uuid) {
      +        $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
      +        $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
      +    }
      +
      +    foreach ($applications as $app) {
      +        if (is_null($app->fqdn)) {
      +            continue;
      +        }
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                $conflicts[] = [
      +                    'domain' => $naked_domain,
      +                    'resource_name' => $app->name,
      +                    'resource_uuid' => $app->uuid,
      +                    'resource_type' => 'application',
      +                    'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
      +                ];
      +            }
      +        }
      +    }
      +
      +    foreach ($serviceApplications as $app) {
      +        if (str($app->fqdn)->isEmpty()) {
      +            continue;
      +        }
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                $conflicts[] = [
      +                    'domain' => $naked_domain,
      +                    'resource_name' => $app->service->name ?? 'Unknown Service',
      +                    'resource_uuid' => $app->uuid,
      +                    'resource_type' => 'service',
      +                    'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
      +                ];
      +            }
      +        }
      +    }
      +
      +    // Check instance-level domain
      +    $settings = instanceSettings();
      +    if (data_get($settings, 'fqdn')) {
      +        $domain = data_get($settings, 'fqdn');
      +        if (str($domain)->endsWith('/')) {
      +            $domain = str($domain)->beforeLast('/');
      +        }
      +        $naked_domain = str($domain)->value();
      +        if ($domains->contains($naked_domain)) {
      +            $conflicts[] = [
      +                'domain' => $naked_domain,
      +                'resource_name' => 'Coolify Instance',
      +                'resource_uuid' => null,
      +                'resource_type' => 'instance',
      +                'message' => "Domain $naked_domain is already in use by this Coolify instance",
      +            ];
      +        }
      +    }
      +
      +    return [
      +        'conflicts' => $conflicts,
      +        'hasConflicts' => count($conflicts) > 0,
      +    ];
      +}
      diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php
      index 0de2f2fd9..3b5f183fb 100644
      --- a/bootstrap/helpers/github.php
      +++ b/bootstrap/helpers/github.php
      @@ -135,7 +135,13 @@ function getPermissionsPath(GithubApp $source)
       
       function loadRepositoryByPage(GithubApp $source, string $token, int $page)
       {
      -    $response = Http::withToken($token)->get("{$source->api_url}/installation/repositories?per_page=100&page={$page}");
      +    $response = Http::GitHub($source->api_url, $token)
      +        ->timeout(20)
      +        ->retry(3, 200, throw: false)
      +        ->get('/installation/repositories', [
      +            'per_page' => 100,
      +            'page' => $page,
      +        ]);
           $json = $response->json();
           if ($response->status() !== 200) {
               return [
      diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
      new file mode 100644
      index 000000000..f2260f0c6
      --- /dev/null
      +++ b/bootstrap/helpers/parsers.php
      @@ -0,0 +1,2229 @@
      +getMessage(), 0, $e);
      +    }
      +
      +    if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
      +        throw new \Exception('Docker Compose file must contain a "services" section');
      +    }
      +    // Validate service names
      +    foreach ($parsed['services'] as $serviceName => $serviceConfig) {
      +        try {
      +            validateShellSafePath($serviceName, 'service name');
      +        } catch (\Exception $e) {
      +            throw new \Exception(
      +                'Invalid Docker Compose service name: '.$e->getMessage().
      +                ' Service names must not contain shell metacharacters.',
      +                0,
      +                $e
      +            );
      +        }
      +
      +        // Validate volumes in this service (both string and array formats)
      +        if (isset($serviceConfig['volumes']) && is_array($serviceConfig['volumes'])) {
      +            foreach ($serviceConfig['volumes'] as $volume) {
      +                if (is_string($volume)) {
      +                    // String format: "source:target" or "source:target:mode"
      +                    validateVolumeStringForInjection($volume);
      +                } elseif (is_array($volume)) {
      +                    // Array format: {type: bind, source: ..., target: ...}
      +                    if (isset($volume['source'])) {
      +                        $source = $volume['source'];
      +                        if (is_string($source)) {
      +                            // Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
      +                            $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
      +                            $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
      +
      +                            if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
      +                                try {
      +                                    validateShellSafePath($source, 'volume source');
      +                                } catch (\Exception $e) {
      +                                    throw new \Exception(
      +                                        'Invalid Docker volume definition (array syntax): '.$e->getMessage().
      +                                        ' Please use safe path names without shell metacharacters.',
      +                                        0,
      +                                        $e
      +                                    );
      +                                }
      +                            }
      +                        }
      +                    }
      +                    if (isset($volume['target'])) {
      +                        $target = $volume['target'];
      +                        if (is_string($target)) {
      +                            try {
      +                                validateShellSafePath($target, 'volume target');
      +                            } catch (\Exception $e) {
      +                                throw new \Exception(
      +                                    'Invalid Docker volume definition (array syntax): '.$e->getMessage().
      +                                    ' Please use safe path names without shell metacharacters.',
      +                                    0,
      +                                    $e
      +                                );
      +                            }
      +                        }
      +                    }
      +                }
      +            }
      +        }
      +    }
      +}
      +
      +/**
      + * Validates a Docker volume string (format: "source:target" or "source:target:mode")
      + *
      + * @param  string  $volumeString  The volume string to validate
      + *
      + * @throws \Exception If the volume string contains command injection attempts
      + */
      +function validateVolumeStringForInjection(string $volumeString): void
      +{
      +    // Canonical parsing also validates and throws on unsafe input
      +    parseDockerVolumeString($volumeString);
      +}
      +
      +function parseDockerVolumeString(string $volumeString): array
      +{
      +    $volumeString = trim($volumeString);
      +    $source = null;
      +    $target = null;
      +    $mode = null;
      +
      +    // First, check if the source contains an environment variable with default value
      +    // This needs to be done before counting colons because ${VAR:-value} contains a colon
      +    $envVarPattern = '/^\$\{[^}]+:-[^}]*\}/';
      +    $hasEnvVarWithDefault = false;
      +    $envVarEndPos = 0;
      +
      +    if (preg_match($envVarPattern, $volumeString, $matches)) {
      +        $hasEnvVarWithDefault = true;
      +        $envVarEndPos = strlen($matches[0]);
      +    }
      +
      +    // Count colons, but exclude those inside environment variables
      +    $effectiveVolumeString = $volumeString;
      +    if ($hasEnvVarWithDefault) {
      +        // Temporarily replace the env var to count colons correctly
      +        $effectiveVolumeString = substr($volumeString, $envVarEndPos);
      +        $colonCount = substr_count($effectiveVolumeString, ':');
      +    } else {
      +        $colonCount = substr_count($volumeString, ':');
      +    }
      +
      +    if ($colonCount === 0) {
      +        // Named volume without target (unusual but valid)
      +        // Example: "myvolume"
      +        $source = $volumeString;
      +        $target = $volumeString;
      +    } elseif ($colonCount === 1) {
      +        // Simple volume mapping
      +        // Examples: "gitea:/data" or "./data:/app/data" or "${VAR:-default}:/data"
      +        if ($hasEnvVarWithDefault) {
      +            $source = substr($volumeString, 0, $envVarEndPos);
      +            $remaining = substr($volumeString, $envVarEndPos);
      +            if (strlen($remaining) > 0 && $remaining[0] === ':') {
      +                $target = substr($remaining, 1);
      +            } else {
      +                $target = $remaining;
      +            }
      +        } else {
      +            $parts = explode(':', $volumeString);
      +            $source = $parts[0];
      +            $target = $parts[1];
      +        }
      +    } elseif ($colonCount === 2) {
      +        // Volume with mode OR Windows path OR env var with mode
      +        // Handle env var with mode first
      +        if ($hasEnvVarWithDefault) {
      +            // ${VAR:-default}:/path:mode
      +            $source = substr($volumeString, 0, $envVarEndPos);
      +            $remaining = substr($volumeString, $envVarEndPos);
      +
      +            if (strlen($remaining) > 0 && $remaining[0] === ':') {
      +                $remaining = substr($remaining, 1);
      +                $lastColon = strrpos($remaining, ':');
      +
      +                if ($lastColon !== false) {
      +                    $possibleMode = substr($remaining, $lastColon + 1);
      +                    $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +                    if (in_array($possibleMode, $validModes)) {
      +                        $mode = $possibleMode;
      +                        $target = substr($remaining, 0, $lastColon);
      +                    } else {
      +                        $target = $remaining;
      +                    }
      +                } else {
      +                    $target = $remaining;
      +                }
      +            }
      +        } elseif (preg_match('/^[A-Za-z]:/', $volumeString)) {
      +            // Windows path as source (C:/, D:/, etc.)
      +            // Find the second colon which is the real separator
      +            $secondColon = strpos($volumeString, ':', 2);
      +            if ($secondColon !== false) {
      +                $source = substr($volumeString, 0, $secondColon);
      +                $target = substr($volumeString, $secondColon + 1);
      +            } else {
      +                // Malformed, treat as is
      +                $source = $volumeString;
      +                $target = $volumeString;
      +            }
      +        } else {
      +            // Not a Windows path, check for mode
      +            $lastColon = strrpos($volumeString, ':');
      +            $possibleMode = substr($volumeString, $lastColon + 1);
      +
      +            // Check if the last part is a valid Docker volume mode
      +            $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +            if (in_array($possibleMode, $validModes)) {
      +                // It's a mode
      +                // Examples: "gitea:/data:ro" or "./data:/app/data:rw"
      +                $mode = $possibleMode;
      +                $volumeWithoutMode = substr($volumeString, 0, $lastColon);
      +                $colonPos = strpos($volumeWithoutMode, ':');
      +
      +                if ($colonPos !== false) {
      +                    $source = substr($volumeWithoutMode, 0, $colonPos);
      +                    $target = substr($volumeWithoutMode, $colonPos + 1);
      +                } else {
      +                    // Shouldn't happen for valid volume strings
      +                    $source = $volumeWithoutMode;
      +                    $target = $volumeWithoutMode;
      +                }
      +            } else {
      +                // The last colon is part of the path
      +                // For now, treat the first occurrence of : as the separator
      +                $firstColon = strpos($volumeString, ':');
      +                $source = substr($volumeString, 0, $firstColon);
      +                $target = substr($volumeString, $firstColon + 1);
      +            }
      +        }
      +    } else {
      +        // More than 2 colons - likely Windows paths or complex cases
      +        // Use a heuristic: find the most likely separator colon
      +        // Look for patterns like "C:" at the beginning (Windows drive)
      +        if (preg_match('/^[A-Za-z]:/', $volumeString)) {
      +            // Windows path as source
      +            // Find the next colon after the drive letter
      +            $secondColon = strpos($volumeString, ':', 2);
      +            if ($secondColon !== false) {
      +                $source = substr($volumeString, 0, $secondColon);
      +                $remaining = substr($volumeString, $secondColon + 1);
      +
      +                // Check if there's a mode at the end
      +                $lastColon = strrpos($remaining, ':');
      +                if ($lastColon !== false) {
      +                    $possibleMode = substr($remaining, $lastColon + 1);
      +                    $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +                    if (in_array($possibleMode, $validModes)) {
      +                        $mode = $possibleMode;
      +                        $target = substr($remaining, 0, $lastColon);
      +                    } else {
      +                        $target = $remaining;
      +                    }
      +                } else {
      +                    $target = $remaining;
      +                }
      +            } else {
      +                // Malformed, treat as is
      +                $source = $volumeString;
      +                $target = $volumeString;
      +            }
      +        } else {
      +            // Try to parse normally, treating first : as separator
      +            $firstColon = strpos($volumeString, ':');
      +            $source = substr($volumeString, 0, $firstColon);
      +            $remaining = substr($volumeString, $firstColon + 1);
      +
      +            // Check for mode at the end
      +            $lastColon = strrpos($remaining, ':');
      +            if ($lastColon !== false) {
      +                $possibleMode = substr($remaining, $lastColon + 1);
      +                $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +                if (in_array($possibleMode, $validModes)) {
      +                    $mode = $possibleMode;
      +                    $target = substr($remaining, 0, $lastColon);
      +                } else {
      +                    $target = $remaining;
      +                }
      +            } else {
      +                $target = $remaining;
      +            }
      +        }
      +    }
      +
      +    // Handle environment variable expansion in source
      +    // Example: ${VOLUME_DB_PATH:-db} should extract default value if present
      +    if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) {
      +        $varContent = $matches[1];
      +
      +        // Check if there's a default value with :-
      +        if (strpos($varContent, ':-') !== false) {
      +            $parts = explode(':-', $varContent, 2);
      +            $varName = $parts[0];
      +            $defaultValue = isset($parts[1]) ? $parts[1] : '';
      +
      +            // If there's a non-empty default value, use it for source
      +            if ($defaultValue !== '') {
      +                $source = $defaultValue;
      +            } else {
      +                // Empty default value, keep the variable reference for env resolution
      +                $source = '${'.$varName.'}';
      +            }
      +        }
      +        // Otherwise keep the variable as-is for later expansion (no default value)
      +    }
      +
      +    // Validate source path for command injection attempts
      +    // We validate the final source value after environment variable processing
      +    if ($source !== null) {
      +        // Allow simple environment variables like ${VAR_NAME} or ${VAR}
      +        // but validate everything else for shell metacharacters
      +        $sourceStr = is_string($source) ? $source : $source;
      +
      +        // Skip validation for simple environment variable references
      +        // Pattern: ${WORD_CHARS} with no special characters inside
      +        $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
      +
      +        if (! $isSimpleEnvVar) {
      +            try {
      +                validateShellSafePath($sourceStr, 'volume source');
      +            } catch (\Exception $e) {
      +                // Re-throw with more context about the volume string
      +                throw new \Exception(
      +                    'Invalid Docker volume definition: '.$e->getMessage().
      +                    ' Please use safe path names without shell metacharacters.'
      +                );
      +            }
      +        }
      +    }
      +
      +    // Also validate target path
      +    if ($target !== null) {
      +        $targetStr = is_string($target) ? $target : $target;
      +        // Target paths in containers are typically absolute paths, so we validate them too
      +        // but they're less likely to be dangerous since they're not used in host commands
      +        // Still, defense in depth is important
      +        try {
      +            validateShellSafePath($targetStr, 'volume target');
      +        } catch (\Exception $e) {
      +            throw new \Exception(
      +                'Invalid Docker volume definition: '.$e->getMessage().
      +                ' Please use safe path names without shell metacharacters.'
      +            );
      +        }
      +    }
      +
      +    return [
      +        'source' => $source !== null ? str($source) : null,
      +        'target' => $target !== null ? str($target) : null,
      +        'mode' => $mode !== null ? str($mode) : null,
      +    ];
      +}
      +
      +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection
      +{
      +    $uuid = data_get($resource, 'uuid');
      +    $compose = data_get($resource, 'docker_compose_raw');
      +    if (! $compose) {
      +        return collect([]);
      +    }
      +
      +    $pullRequestId = $pull_request_id;
      +    $isPullRequest = $pullRequestId == 0 ? false : true;
      +    $server = data_get($resource, 'destination.server');
      +    $fileStorages = $resource->fileStorages();
      +
      +    try {
      +        $yaml = Yaml::parse($compose);
      +    } catch (\Exception) {
      +        return collect([]);
      +    }
      +    $services = data_get($yaml, 'services', collect([]));
      +    $topLevel = collect([
      +        'volumes' => collect(data_get($yaml, 'volumes', [])),
      +        'networks' => collect(data_get($yaml, 'networks', [])),
      +        'configs' => collect(data_get($yaml, 'configs', [])),
      +        'secrets' => collect(data_get($yaml, 'secrets', [])),
      +    ]);
      +    // If there are predefined volumes, make sure they are not null
      +    if ($topLevel->get('volumes')->count() > 0) {
      +        $temp = collect([]);
      +        foreach ($topLevel['volumes'] as $volumeName => $volume) {
      +            if (is_null($volume)) {
      +                continue;
      +            }
      +            $temp->put($volumeName, $volume);
      +        }
      +        $topLevel['volumes'] = $temp;
      +    }
      +    // Get the base docker network
      +    $baseNetwork = collect([$uuid]);
      +    if ($isPullRequest) {
      +        $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]);
      +    }
      +
      +    $parsedServices = collect([]);
      +
      +    $allMagicEnvironments = collect([]);
      +    foreach ($services as $serviceName => $service) {
      +        // Validate service name for command injection
      +        try {
      +            validateShellSafePath($serviceName, 'service name');
      +        } catch (\Exception $e) {
      +            throw new \Exception(
      +                'Invalid Docker Compose service name: '.$e->getMessage().
      +                ' Service names must not contain shell metacharacters.'
      +            );
      +        }
      +
      +        $magicEnvironments = collect([]);
      +        $image = data_get_str($service, 'image');
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        // convert environment variables to one format
      +        $environment = convertToKeyValueCollection($environment);
      +
      +        // Add Coolify defined environments
      +        $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
      +
      +        $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
      +            return [$item['key'] => $item['value']];
      +        });
      +        // filter and add magic environments
      +        foreach ($environment as $key => $value) {
      +            // Get all SERVICE_ variables from keys and values
      +            $key = str($key);
      +            $value = str($value);
      +            $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
      +            preg_match_all($regex, $value, $valueMatches);
      +            if (count($valueMatches[1]) > 0) {
      +                foreach ($valueMatches[1] as $match) {
      +                    $match = replaceVariables($match);
      +                    if ($match->startsWith('SERVICE_')) {
      +                        if ($magicEnvironments->has($match->value())) {
      +                            continue;
      +                        }
      +                        $magicEnvironments->put($match->value(), '');
      +                    }
      +                }
      +            }
      +            // Get magic environments where we need to preset the FQDN
      +            // for example SERVICE_FQDN_APP_3000 (without a value)
      +            if ($key->startsWith('SERVICE_FQDN_')) {
      +                // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
      +                    $port = $key->afterLast('_')->value();
      +                } else {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    $port = null;
      +                }
      +                $fqdn = $resource->fqdn;
      +                if (blank($resource->fqdn)) {
      +                    $fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
      +                }
      +
      +                if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
      +                    $path = $value->value();
      +                    if ($path !== '/') {
      +                        $fqdn = "$fqdn$path";
      +                    }
      +                }
      +                $fqdnWithPort = $fqdn;
      +                if ($port) {
      +                    $fqdnWithPort = "$fqdn:$port";
      +                }
      +                if (is_null($resource->fqdn)) {
      +                    data_forget($resource, 'environment_variables');
      +                    data_forget($resource, 'environment_variables_preview');
      +                    $resource->fqdn = $fqdnWithPort;
      +                    $resource->save();
      +                }
      +
      +                if (substr_count(str($key)->value(), '_') === 2) {
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +
      +                    $newKey = str($key)->beforeLast('_');
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $newKey->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
      +        if ($magicEnvironments->count() > 0) {
      +            // Generate Coolify environment variables
      +            foreach ($magicEnvironments as $key => $value) {
      +                $key = str($key);
      +                $value = replaceVariables($value);
      +                $command = parseCommandFromMagicEnvVariable($key);
      +                if ($command->value() === 'FQDN') {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    $originalFqdnFor = str($fqdnFor)->replace('_', '-');
      +                    if (str($fqdnFor)->contains('-')) {
      +                        $fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
      +                    }
      +                    // Generated FQDN & URL
      +                    $fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    $url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                    if ($resource->build_pack === 'dockercompose') {
      +                        // Check if a service with this name actually exists
      +                        $serviceExists = false;
      +                        foreach ($services as $serviceName => $service) {
      +                            $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
      +                            if ($transformedServiceName === $fqdnFor) {
      +                                $serviceExists = true;
      +                                break;
      +                            }
      +                        }
      +
      +                        // Only add domain if the service exists
      +                        if ($serviceExists) {
      +                            $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
      +                            $domainExists = data_get($domains->get($fqdnFor), 'domain');
      +                            $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                            if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
      +                                $envExists->update([
      +                                    'value' => $url,
      +                                ]);
      +                            }
      +                            if (is_null($domainExists)) {
      +                                // Put URL in the domains array instead of FQDN
      +                                $domains->put((string) $fqdnFor, [
      +                                    'domain' => $url,
      +                                ]);
      +                                $resource->docker_compose_domains = $domains->toJson();
      +                                $resource->save();
      +                            }
      +                        }
      +                    }
      +                } elseif ($command->value() === 'URL') {
      +                    $urlFor = $key->after('SERVICE_URL_')->lower()->value();
      +                    $originalUrlFor = str($urlFor)->replace('_', '-');
      +                    if (str($urlFor)->contains('-')) {
      +                        $urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
      +                    }
      +                    $url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +                    if ($resource->build_pack === 'dockercompose') {
      +                        // Check if a service with this name actually exists
      +                        $serviceExists = false;
      +                        foreach ($services as $serviceName => $service) {
      +                            $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
      +                            if ($transformedServiceName === $urlFor) {
      +                                $serviceExists = true;
      +                                break;
      +                            }
      +                        }
      +
      +                        // Only add domain if the service exists
      +                        if ($serviceExists) {
      +                            $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
      +                            $domainExists = data_get($domains->get($urlFor), 'domain');
      +                            $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                            if ($domainExists !== $envExists->value) {
      +                                $envExists->update([
      +                                    'value' => $url,
      +                                ]);
      +                            }
      +                            if (is_null($domainExists)) {
      +                                $domains->put((string) $urlFor, [
      +                                    'domain' => $url,
      +                                ]);
      +                                $resource->docker_compose_domains = $domains->toJson();
      +                                $resource->save();
      +                            }
      +                        }
      +                    }
      +                } else {
      +                    $value = generateEnvValue($command, $resource);
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +    }
      +
      +    // generate SERVICE_NAME variables for docker compose services
      +    $serviceNameEnvironments = collect([]);
      +    if ($resource->build_pack === 'dockercompose') {
      +        $serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
      +    }
      +
      +    // Parse the rest of the services
      +    foreach ($services as $serviceName => $service) {
      +        $image = data_get_str($service, 'image');
      +        $restart = data_get_str($service, 'restart', RESTART_MODE);
      +        $logging = data_get($service, 'logging');
      +
      +        if ($server->isLogDrainEnabled()) {
      +            if ($resource->isLogDrainEnabled()) {
      +                $logging = generate_fluentd_configuration();
      +            }
      +        }
      +        $volumes = collect(data_get($service, 'volumes', []));
      +        $networks = collect(data_get($service, 'networks', []));
      +        $use_network_mode = data_get($service, 'network_mode') !== null;
      +        $depends_on = collect(data_get($service, 'depends_on', []));
      +        $labels = collect(data_get($service, 'labels', []));
      +        if ($labels->count() > 0) {
      +            if (isAssociativeArray($labels)) {
      +                $newLabels = collect([]);
      +                $labels->each(function ($value, $key) use ($newLabels) {
      +                    $newLabels->push("$key=$value");
      +                });
      +                $labels = $newLabels;
      +            }
      +        }
      +        $environment = collect(data_get($service, 'environment', []));
      +        $ports = collect(data_get($service, 'ports', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        $environment = convertToKeyValueCollection($environment);
      +        $coolifyEnvironments = collect([]);
      +
      +        $isDatabase = isDatabaseImage($image, $service);
      +        $volumesParsed = collect([]);
      +
      +        $baseName = generateApplicationContainerName(
      +            application: $resource,
      +            pull_request_id: $pullRequestId
      +        );
      +        $containerName = "$serviceName-$baseName";
      +        $predefinedPort = null;
      +
      +        $originalResource = $resource;
      +
      +        if ($volumes->count() > 0) {
      +            foreach ($volumes as $index => $volume) {
      +                $type = null;
      +                $source = null;
      +                $target = null;
      +                $content = null;
      +                $isDirectory = false;
      +                if (is_string($volume)) {
      +                    $parsed = parseDockerVolumeString($volume);
      +                    $source = $parsed['source'];
      +                    $target = $parsed['target'];
      +                    // Mode is available in $parsed['mode'] if needed
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if (sourceIsLocal($source)) {
      +                        $type = str('bind');
      +                        if ($foundConfig) {
      +                            $contentNotNull_temp = data_get($foundConfig, 'content');
      +                            if ($contentNotNull_temp) {
      +                                $content = $contentNotNull_temp;
      +                            }
      +                            $isDirectory = data_get($foundConfig, 'is_directory');
      +                        } else {
      +                            // By default, we cannot determine if the bind is a directory or not, so we set it to directory
      +                            $isDirectory = true;
      +                        }
      +                    } else {
      +                        $type = str('volume');
      +                    }
      +                } elseif (is_array($volume)) {
      +                    $type = data_get_str($volume, 'type');
      +                    $source = data_get_str($volume, 'source');
      +                    $target = data_get_str($volume, 'target');
      +                    $content = data_get($volume, 'content');
      +                    $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
      +
      +                    // Validate source and target for command injection (array/long syntax)
      +                    if ($source !== null && ! empty($source->value())) {
      +                        $sourceValue = $source->value();
      +                        // Allow simple environment variable references
      +                        $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
      +                        if (! $isSimpleEnvVar) {
      +                            try {
      +                                validateShellSafePath($sourceValue, 'volume source');
      +                            } catch (\Exception $e) {
      +                                throw new \Exception(
      +                                    'Invalid Docker volume definition (array syntax): '.$e->getMessage().
      +                                    ' Please use safe path names without shell metacharacters.'
      +                                );
      +                            }
      +                        }
      +                    }
      +                    if ($target !== null && ! empty($target->value())) {
      +                        try {
      +                            validateShellSafePath($target->value(), 'volume target');
      +                        } catch (\Exception $e) {
      +                            throw new \Exception(
      +                                'Invalid Docker volume definition (array syntax): '.$e->getMessage().
      +                                ' Please use safe path names without shell metacharacters.'
      +                            );
      +                        }
      +                    }
      +
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if ($foundConfig) {
      +                        $contentNotNull_temp = data_get($foundConfig, 'content');
      +                        if ($contentNotNull_temp) {
      +                            $content = $contentNotNull_temp;
      +                        }
      +                        $isDirectory = data_get($foundConfig, 'is_directory');
      +                    } else {
      +                        // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
      +                        if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
      +                            $isDirectory = true;
      +                        }
      +                    }
      +                }
      +                if ($type->value() === 'bind') {
      +                    if ($source->value() === '/var/run/docker.sock') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } else {
      +                        if ((int) $resource->compose_parsing_version >= 4) {
      +                            $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
      +                        } else {
      +                            $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
      +                        }
      +                        $source = replaceLocalSource($source, $mainDirectory);
      +                        if ($isPullRequest) {
      +                            $source = addPreviewDeploymentSuffix($source, $pull_request_id);
      +                        }
      +                        LocalFileVolume::updateOrCreate(
      +                            [
      +                                'mount_path' => $target,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ],
      +                            [
      +                                'fs_path' => $source,
      +                                'mount_path' => $target,
      +                                'content' => $content,
      +                                'is_directory' => $isDirectory,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ]
      +                        );
      +                        if (isDev()) {
      +                            if ((int) $resource->compose_parsing_version >= 4) {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
      +                            } else {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
      +                            }
      +                        }
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    }
      +                } elseif ($type->value() === 'volume') {
      +                    if ($topLevel->get('volumes')->has($source->value())) {
      +                        $temp = $topLevel->get('volumes')->get($source->value());
      +                        if (data_get($temp, 'driver_opts.type') === 'cifs') {
      +                            continue;
      +                        }
      +                        if (data_get($temp, 'driver_opts.type') === 'nfs') {
      +                            continue;
      +                        }
      +                    }
      +                    $slugWithoutUuid = Str::slug($source, '-');
      +                    $name = "{$uuid}_{$slugWithoutUuid}";
      +
      +                    if ($isPullRequest) {
      +                        $name = addPreviewDeploymentSuffix($name, $pull_request_id);
      +                    }
      +                    if (is_string($volume)) {
      +                        $parsed = parseDockerVolumeString($volume);
      +                        $source = $parsed['source'];
      +                        $target = $parsed['target'];
      +                        $source = $name;
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif (is_array($volume)) {
      +                        data_set($volume, 'source', $name);
      +                    }
      +                    $topLevel->get('volumes')->put($name, [
      +                        'name' => $name,
      +                    ]);
      +                    LocalPersistentVolume::updateOrCreate(
      +                        [
      +                            'name' => $name,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ],
      +                        [
      +                            'name' => $name,
      +                            'mount_path' => $target,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ]
      +                    );
      +                }
      +                dispatch(new ServerFilesFromServerJob($originalResource));
      +                $volumesParsed->put($index, $volume);
      +            }
      +        }
      +
      +        if ($depends_on?->count() > 0) {
      +            if ($isPullRequest) {
      +                $newDependsOn = collect([]);
      +                $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
      +                    if (is_numeric($condition)) {
      +                        $dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
      +
      +                        $newDependsOn->put($condition, $dependency);
      +                    } else {
      +                        $condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
      +                        $newDependsOn->put($condition, $dependency);
      +                    }
      +                });
      +                $depends_on = $newDependsOn;
      +            }
      +        }
      +        if (! $use_network_mode) {
      +            if ($topLevel->get('networks')?->count() > 0) {
      +                foreach ($topLevel->get('networks') as $networkName => $network) {
      +                    if ($networkName === 'default') {
      +                        continue;
      +                    }
      +                    // ignore aliases
      +                    if ($network['aliases'] ?? false) {
      +                        continue;
      +                    }
      +                    $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
      +                        return $value == $networkName || $key == $networkName;
      +                    });
      +                    if (! $networkExists) {
      +                        $networks->put($networkName, null);
      +                    }
      +                }
      +            }
      +            $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
      +                return $value == $baseNetwork;
      +            });
      +            if (! $baseNetworkExists) {
      +                foreach ($baseNetwork as $network) {
      +                    $topLevel->get('networks')->put($network, [
      +                        'name' => $network,
      +                        'external' => true,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        // Collect/create/update ports
      +        $collectedPorts = collect([]);
      +        if ($ports->count() > 0) {
      +            foreach ($ports as $sport) {
      +                if (is_string($sport) || is_numeric($sport)) {
      +                    $collectedPorts->push($sport);
      +                }
      +                if (is_array($sport)) {
      +                    $target = data_get($sport, 'target');
      +                    $published = data_get($sport, 'published');
      +                    $protocol = data_get($sport, 'protocol');
      +                    $collectedPorts->push("$target:$published/$protocol");
      +                }
      +            }
      +        }
      +
      +        $networks_temp = collect();
      +
      +        if (! $use_network_mode) {
      +            foreach ($networks as $key => $network) {
      +                if (gettype($network) === 'string') {
      +                    // networks:
      +                    //  - appwrite
      +                    $networks_temp->put($network, null);
      +                } elseif (gettype($network) === 'array') {
      +                    // networks:
      +                    //   default:
      +                    //     ipv4_address: 192.168.203.254
      +                    $networks_temp->put($key, $network);
      +                }
      +            }
      +            foreach ($baseNetwork as $key => $network) {
      +                $networks_temp->put($network, null);
      +            }
      +
      +            if (data_get($resource, 'settings.connect_to_docker_network')) {
      +                $network = $resource->destination->network;
      +                $networks_temp->put($network, null);
      +                $topLevel->get('networks')->put($network, [
      +                    'name' => $network,
      +                    'external' => true,
      +                ]);
      +            }
      +        }
      +
      +        $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
      +        $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
      +            return ! str($value)->startsWith('SERVICE_');
      +        });
      +        foreach ($normalEnvironments as $key => $value) {
      +            $key = str($key);
      +            $value = str($value);
      +            $originalValue = $value;
      +            $parsedValue = replaceVariables($value);
      +            if ($value->startsWith('$SERVICE_')) {
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +
      +                continue;
      +            }
      +            if (! $value->startsWith('$')) {
      +                continue;
      +            }
      +            if ($key->value() === $parsedValue->value()) {
      +                $value = null;
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +            } else {
      +                if ($value->startsWith('$')) {
      +                    $isRequired = false;
      +                    if ($value->contains(':-')) {
      +                        $value = replaceVariables($value);
      +                        $key = $value->before(':');
      +                        $value = $value->after(':-');
      +                    } elseif ($value->contains('-')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('-');
      +                        $value = $value->after('-');
      +                    } elseif ($value->contains(':?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before(':');
      +                        $value = $value->after(':?');
      +                        $isRequired = true;
      +                    } elseif ($value->contains('?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('?');
      +                        $value = $value->after('?');
      +                        $isRequired = true;
      +                    }
      +                    if ($originalValue->value() === $value->value()) {
      +                        // This means the variable does not have a default value, so it needs to be created in Coolify
      +                        $parsedKeyValue = replaceVariables($value);
      +                        $resource->environment_variables()->firstOrCreate([
      +                            'key' => $parsedKeyValue,
      +                            'resourceable_type' => get_class($resource),
      +                            'resourceable_id' => $resource->id,
      +                        ], [
      +                            'is_preview' => false,
      +                            'is_required' => $isRequired,
      +                        ]);
      +                        // Add the variable to the environment so it will be shown in the deployable compose file
      +                        $environment[$parsedKeyValue->value()] = $value;
      +
      +                        continue;
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key,
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                        'is_required' => $isRequired,
      +                    ]);
      +                }
      +            }
      +        }
      +        $branch = $originalResource->git_branch;
      +        if ($pullRequestId !== 0) {
      +            $branch = "pull/{$pullRequestId}/head";
      +        }
      +        if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\"");
      +        }
      +
      +        // Add COOLIFY_RESOURCE_UUID to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
      +        }
      +
      +        // Add COOLIFY_CONTAINER_NAME to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
      +        }
      +
      +        if ($isPullRequest) {
      +            $preview = $resource->previews()->find($preview_id);
      +            $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
      +        } else {
      +            $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
      +        }
      +
      +        // Only process domains for dockercompose applications to prevent SERVICE variable recreation
      +        if ($resource->build_pack !== 'dockercompose') {
      +            $domains = collect([]);
      +        }
      +        $changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
      +        $fqdns = data_get($domains, "$changedServiceName.domain");
      +        // Generate SERVICE_FQDN & SERVICE_URL for dockercompose
      +        if ($resource->build_pack === 'dockercompose') {
      +            foreach ($domains as $forServiceName => $domain) {
      +                $parsedDomain = data_get($domain, 'domain');
      +                $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
      +
      +                if (filled($parsedDomain)) {
      +                    $parsedDomain = str($parsedDomain)->explode(',')->first();
      +                    $coolifyUrl = Url::fromString($parsedDomain);
      +                    $coolifyScheme = $coolifyUrl->getScheme();
      +                    $coolifyFqdn = $coolifyUrl->getHost();
      +                    $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
      +                    $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
      +                    $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'resourceable_type' => Application::class,
      +                        'resourceable_id' => $resource->id,
      +                        'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
      +                    ], [
      +                        'value' => $coolifyUrl->__toString(),
      +                        'is_preview' => false,
      +                    ]);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'resourceable_type' => Application::class,
      +                        'resourceable_id' => $resource->id,
      +                        'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
      +                    ], [
      +                        'value' => $coolifyFqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                } else {
      +                    $resource->environment_variables()->where('resourceable_type', Application::class)
      +                        ->where('resourceable_id', $resource->id)
      +                        ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
      +                        ->update([
      +                            'value' => null,
      +                        ]);
      +                    $resource->environment_variables()->where('resourceable_type', Application::class)
      +                        ->where('resourceable_id', $resource->id)
      +                        ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
      +                        ->update([
      +                            'value' => null,
      +                        ]);
      +                }
      +            }
      +        }
      +        // If the domain is set, we need to generate the FQDNs for the preview
      +        if (filled($fqdns)) {
      +            $fqdns = str($fqdns)->explode(',');
      +            if ($isPullRequest) {
      +                $preview = $resource->previews()->find($preview_id);
      +                $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
      +                if ($docker_compose_domains->count() > 0) {
      +                    $found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain");
      +                    if ($found_fqdn) {
      +                        $fqdns = collect($found_fqdn);
      +                    } else {
      +                        $fqdns = collect([]);
      +                    }
      +                } else {
      +                    $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) {
      +                        $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId);
      +                        $url = Url::fromString($fqdn);
      +                        $template = $resource->preview_url_template;
      +                        $host = $url->getHost();
      +                        $schema = $url->getScheme();
      +                        $random = new Cuid2;
      +                        $preview_fqdn = str_replace('{{random}}', $random, $template);
      +                        $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
      +                        $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
      +                        $preview_fqdn = "$schema://$preview_fqdn";
      +                        $preview->fqdn = $preview_fqdn;
      +                        $preview->save();
      +
      +                        return $preview_fqdn;
      +                    });
      +                }
      +            }
      +        }
      +        $defaultLabels = defaultLabels(
      +            id: $resource->id,
      +            name: $containerName,
      +            projectName: $resource->project()->name,
      +            resourceName: $resource->name,
      +            pull_request_id: $pullRequestId,
      +            type: 'application',
      +            environment: $resource->environment->name,
      +        );
      +
      +        $isDatabase = isDatabaseImage($image, $service);
      +        // Add COOLIFY_FQDN & COOLIFY_URL to environment
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
      +                return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
      +            });
      +            $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
      +
      +            $urls = $fqdns->map(function ($fqdn) {
      +                return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
      +            });
      +            $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
      +        }
      +        add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
      +        if ($environment->count() > 0) {
      +            $environment = $environment->filter(function ($value, $key) {
      +                return ! str($key)->startsWith('SERVICE_FQDN_');
      +            })->map(function ($value, $key) use ($resource) {
      +                // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
      +                if (str($value)->isEmpty()) {
      +                    if ($resource->environment_variables()->where('key', $key)->exists()) {
      +                        $value = $resource->environment_variables()->where('key', $key)->first()->value;
      +                    } else {
      +                        $value = null;
      +                    }
      +                }
      +
      +                return $value;
      +            });
      +        }
      +        $serviceLabels = $labels->merge($defaultLabels);
      +        if ($serviceLabels->count() > 0) {
      +            $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled');
      +            if ($isContainerLabelEscapeEnabled) {
      +                $serviceLabels = $serviceLabels->map(function ($value, $key) {
      +                    return escapeDollarSign($value);
      +                });
      +            }
      +        }
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
      +            $uuid = $resource->uuid;
      +            $network = data_get($resource, 'destination.network');
      +            if ($isPullRequest) {
      +                $uuid = "{$resource->uuid}-{$pullRequestId}";
      +            }
      +            if ($isPullRequest) {
      +                $network = "{$resource->destination->network}-{$pullRequestId}";
      +            }
      +            if ($shouldGenerateLabelsExactly) {
      +                switch ($server->proxyType()) {
      +                    case ProxyTypes::TRAEFIK->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image
      +                        ));
      +                        break;
      +                    case ProxyTypes::CADDY->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                            network: $network,
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image,
      +                            predefinedPort: $predefinedPort
      +                        ));
      +                        break;
      +                }
      +            } else {
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image
      +                ));
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                    network: $network,
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image,
      +                    predefinedPort: $predefinedPort
      +                ));
      +            }
      +        }
      +        data_forget($service, 'volumes.*.content');
      +        data_forget($service, 'volumes.*.isDirectory');
      +        data_forget($service, 'volumes.*.is_directory');
      +        data_forget($service, 'exclude_from_hc');
      +
      +        $volumesParsed = $volumesParsed->map(function ($volume) {
      +            data_forget($volume, 'content');
      +            data_forget($volume, 'is_directory');
      +            data_forget($volume, 'isDirectory');
      +
      +            return $volume;
      +        });
      +
      +        $payload = collect($service)->merge([
      +            'container_name' => $containerName,
      +            'restart' => $restart->value(),
      +            'labels' => $serviceLabels,
      +        ]);
      +        if (! $use_network_mode) {
      +            $payload['networks'] = $networks_temp;
      +        }
      +        if ($ports->count() > 0) {
      +            $payload['ports'] = $ports;
      +        }
      +        if ($volumesParsed->count() > 0) {
      +            $payload['volumes'] = $volumesParsed;
      +        }
      +        if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
      +            $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
      +        }
      +        if ($logging) {
      +            $payload['logging'] = $logging;
      +        }
      +        if ($depends_on->count() > 0) {
      +            $payload['depends_on'] = $depends_on;
      +        }
      +        if ($isPullRequest) {
      +            $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
      +        }
      +
      +        $parsedServices->put($serviceName, $payload);
      +    }
      +    $topLevel->put('services', $parsedServices);
      +
      +    $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
      +
      +    $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
      +        return array_search($key, $customOrder);
      +    });
      +
      +    $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
      +    data_forget($resource, 'environment_variables');
      +    data_forget($resource, 'environment_variables_preview');
      +    $resource->save();
      +
      +    return $topLevel;
      +}
      +
      +function serviceParser(Service $resource): Collection
      +{
      +    $uuid = data_get($resource, 'uuid');
      +    $compose = data_get($resource, 'docker_compose_raw');
      +    if (! $compose) {
      +        return collect([]);
      +    }
      +
      +    $server = data_get($resource, 'server');
      +    $allServices = get_service_templates();
      +
      +    try {
      +        $yaml = Yaml::parse($compose);
      +    } catch (\Exception) {
      +        return collect([]);
      +    }
      +    $services = data_get($yaml, 'services', collect([]));
      +    $topLevel = collect([
      +        'volumes' => collect(data_get($yaml, 'volumes', [])),
      +        'networks' => collect(data_get($yaml, 'networks', [])),
      +        'configs' => collect(data_get($yaml, 'configs', [])),
      +        'secrets' => collect(data_get($yaml, 'secrets', [])),
      +    ]);
      +    // If there are predefined volumes, make sure they are not null
      +    if ($topLevel->get('volumes')->count() > 0) {
      +        $temp = collect([]);
      +        foreach ($topLevel['volumes'] as $volumeName => $volume) {
      +            if (is_null($volume)) {
      +                continue;
      +            }
      +            $temp->put($volumeName, $volume);
      +        }
      +        $topLevel['volumes'] = $temp;
      +    }
      +    // Get the base docker network
      +    $baseNetwork = collect([$uuid]);
      +
      +    $parsedServices = collect([]);
      +
      +    // Generate SERVICE_NAME variables for docker compose services
      +    $serviceNameEnvironments = generateDockerComposeServiceName($services);
      +
      +    $allMagicEnvironments = collect([]);
      +    // Presave services
      +    foreach ($services as $serviceName => $service) {
      +        // Validate service name for command injection
      +        try {
      +            validateShellSafePath($serviceName, 'service name');
      +        } catch (\Exception $e) {
      +            throw new \Exception(
      +                'Invalid Docker Compose service name: '.$e->getMessage().
      +                ' Service names must not contain shell metacharacters.'
      +            );
      +        }
      +
      +        $image = data_get_str($service, 'image');
      +        $isDatabase = isDatabaseImage($image, $service);
      +        if ($isDatabase) {
      +            $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
      +            if ($applicationFound) {
      +                $savedService = $applicationFound;
      +            } else {
      +                $savedService = ServiceDatabase::firstOrCreate([
      +                    'name' => $serviceName,
      +                    'service_id' => $resource->id,
      +                ]);
      +            }
      +        } else {
      +            $savedService = ServiceApplication::firstOrCreate([
      +                'name' => $serviceName,
      +                'service_id' => $resource->id,
      +            ]);
      +        }
      +        // Update image if it changed
      +        if ($savedService->image !== $image) {
      +            $savedService->image = $image;
      +            $savedService->save();
      +        }
      +    }
      +    foreach ($services as $serviceName => $service) {
      +        $predefinedPort = null;
      +        $magicEnvironments = collect([]);
      +        $image = data_get_str($service, 'image');
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +        $isDatabase = isDatabaseImage($image, $service);
      +
      +        $containerName = "$serviceName-{$resource->uuid}";
      +
      +        if ($serviceName === 'registry') {
      +            $tempServiceName = 'docker-registry';
      +        } else {
      +            $tempServiceName = $serviceName;
      +        }
      +        if (str(data_get($service, 'image'))->contains('glitchtip')) {
      +            $tempServiceName = 'glitchtip';
      +        }
      +        if ($serviceName === 'supabase-kong') {
      +            $tempServiceName = 'supabase';
      +        }
      +        $serviceDefinition = data_get($allServices, $tempServiceName);
      +        $predefinedPort = data_get($serviceDefinition, 'port');
      +        if ($serviceName === 'plausible') {
      +            $predefinedPort = '8000';
      +        }
      +        if ($isDatabase) {
      +            $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
      +            if ($applicationFound) {
      +                $savedService = $applicationFound;
      +            } else {
      +                $savedService = ServiceDatabase::firstOrCreate([
      +                    'name' => $serviceName,
      +                    'service_id' => $resource->id,
      +                ]);
      +            }
      +        } else {
      +            $savedService = ServiceApplication::firstOrCreate([
      +                'name' => $serviceName,
      +                'service_id' => $resource->id,
      +            ], [
      +                'is_gzip_enabled' => true,
      +            ]);
      +        }
      +        // Check if image changed
      +        if ($savedService->image !== $image) {
      +            $savedService->image = $image;
      +            $savedService->save();
      +        }
      +        // Pocketbase does not need gzip for SSE.
      +        if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
      +            $savedService->is_gzip_enabled = false;
      +            $savedService->save();
      +        }
      +
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        // convert environment variables to one format
      +        $environment = convertToKeyValueCollection($environment);
      +
      +        // Add Coolify defined environments
      +        $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
      +
      +        $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
      +            return [$item['key'] => $item['value']];
      +        });
      +        // filter and add magic environments
      +        foreach ($environment as $key => $value) {
      +            // Get all SERVICE_ variables from keys and values
      +            $key = str($key);
      +            $value = str($value);
      +            $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
      +            preg_match_all($regex, $value, $valueMatches);
      +            if (count($valueMatches[1]) > 0) {
      +                foreach ($valueMatches[1] as $match) {
      +                    $match = replaceVariables($match);
      +                    if ($match->startsWith('SERVICE_')) {
      +                        if ($magicEnvironments->has($match->value())) {
      +                            continue;
      +                        }
      +                        $magicEnvironments->put($match->value(), '');
      +                    }
      +                }
      +            }
      +            // Get magic environments where we need to preset the FQDN / URL
      +            if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
      +                // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +                    if ($key->startsWith('SERVICE_FQDN_')) {
      +                        $urlFor = null;
      +                        $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
      +                    }
      +                    if ($key->startsWith('SERVICE_URL_')) {
      +                        $fqdnFor = null;
      +                        $urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
      +                    }
      +                    $port = $key->afterLast('_')->value();
      +                } else {
      +                    if ($key->startsWith('SERVICE_FQDN_')) {
      +                        $urlFor = null;
      +                        $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    }
      +                    if ($key->startsWith('SERVICE_URL_')) {
      +                        $fqdnFor = null;
      +                        $urlFor = $key->after('SERVICE_URL_')->lower()->value();
      +                    }
      +                    $port = null;
      +                }
      +                if (blank($savedService->fqdn)) {
      +                    if ($fqdnFor) {
      +                        $fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    } else {
      +                        $fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    }
      +                    if ($urlFor) {
      +                        $url = generateUrl($server, "$urlFor-$uuid");
      +                    } else {
      +                        $url = generateUrl($server, "{$savedService->name}-$uuid");
      +                    }
      +                } else {
      +                    $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
      +                    $url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
      +                }
      +
      +                if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
      +                    $path = $value->value();
      +                    if ($path !== '/') {
      +                        $fqdn = "$fqdn$path";
      +                        $url = "$url$path";
      +                    }
      +                }
      +                $fqdnWithPort = $fqdn;
      +                $urlWithPort = $url;
      +                if ($fqdn && $port) {
      +                    $fqdnWithPort = "$fqdn:$port";
      +                }
      +                if ($url && $port) {
      +                    $urlWithPort = "$url:$port";
      +                }
      +                if (is_null($savedService->fqdn)) {
      +                    if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
      +                        if ($fqdnFor) {
      +                            $savedService->fqdn = $fqdnWithPort;
      +                        }
      +                        if ($urlFor) {
      +                            $savedService->fqdn = $urlWithPort;
      +                        }
      +                    } else {
      +                        $savedService->fqdn = $fqdnWithPort;
      +                    }
      +                    $savedService->save();
      +                }
      +                if (substr_count(str($key)->value(), '_') === 2) {
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +                    $newKey = str($key)->beforeLast('_');
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $newKey->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $newKey->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +        $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
      +        if ($magicEnvironments->count() > 0) {
      +            foreach ($magicEnvironments as $key => $value) {
      +                $key = str($key);
      +                $value = replaceVariables($value);
      +                $command = parseCommandFromMagicEnvVariable($key);
      +                if ($command->value() === 'FQDN') {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    $fqdn = generateFqdn(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    $url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
      +
      +                    $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                    $serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
      +                    if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
      +                        // Save URL otherwise it won't work.
      +                        $serviceExists->fqdn = $url;
      +                        $serviceExists->save();
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +
      +                } elseif ($command->value() === 'URL') {
      +                    $urlFor = $key->after('SERVICE_URL_')->lower()->value();
      +                    $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
      +
      +                    $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                    $serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
      +                    if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
      +                        $serviceExists->fqdn = $url;
      +                        $serviceExists->save();
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +
      +                } else {
      +                    $value = generateEnvValue($command, $resource);
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +    }
      +
      +    $serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
      +        return $app->isLogDrainEnabled();
      +    });
      +
      +    // Parse the rest of the services
      +    foreach ($services as $serviceName => $service) {
      +        $image = data_get_str($service, 'image');
      +        $restart = data_get_str($service, 'restart', RESTART_MODE);
      +        $logging = data_get($service, 'logging');
      +
      +        if ($server->isLogDrainEnabled()) {
      +            if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
      +                $logging = generate_fluentd_configuration();
      +            }
      +        }
      +        $volumes = collect(data_get($service, 'volumes', []));
      +        $networks = collect(data_get($service, 'networks', []));
      +        $use_network_mode = data_get($service, 'network_mode') !== null;
      +        $depends_on = collect(data_get($service, 'depends_on', []));
      +        $labels = collect(data_get($service, 'labels', []));
      +        if ($labels->count() > 0) {
      +            if (isAssociativeArray($labels)) {
      +                $newLabels = collect([]);
      +                $labels->each(function ($value, $key) use ($newLabels) {
      +                    $newLabels->push("$key=$value");
      +                });
      +                $labels = $newLabels;
      +            }
      +        }
      +        $environment = collect(data_get($service, 'environment', []));
      +        $ports = collect(data_get($service, 'ports', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        $environment = convertToKeyValueCollection($environment);
      +        $coolifyEnvironments = collect([]);
      +
      +        $isDatabase = isDatabaseImage($image, $service);
      +        $volumesParsed = collect([]);
      +
      +        $containerName = "$serviceName-{$resource->uuid}";
      +
      +        if ($serviceName === 'registry') {
      +            $tempServiceName = 'docker-registry';
      +        } else {
      +            $tempServiceName = $serviceName;
      +        }
      +        if (str(data_get($service, 'image'))->contains('glitchtip')) {
      +            $tempServiceName = 'glitchtip';
      +        }
      +        if ($serviceName === 'supabase-kong') {
      +            $tempServiceName = 'supabase';
      +        }
      +        $serviceDefinition = data_get($allServices, $tempServiceName);
      +        $predefinedPort = data_get($serviceDefinition, 'port');
      +        if ($serviceName === 'plausible') {
      +            $predefinedPort = '8000';
      +        }
      +
      +        if ($isDatabase) {
      +            $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
      +            if ($applicationFound) {
      +                $savedService = $applicationFound;
      +            } else {
      +                $savedService = ServiceDatabase::firstOrCreate([
      +                    'name' => $serviceName,
      +                    'service_id' => $resource->id,
      +                ]);
      +            }
      +        } else {
      +            $savedService = ServiceApplication::firstOrCreate([
      +                'name' => $serviceName,
      +                'service_id' => $resource->id,
      +            ]);
      +        }
      +        $fileStorages = $savedService->fileStorages();
      +        if ($savedService->image !== $image) {
      +            $savedService->image = $image;
      +            $savedService->save();
      +        }
      +
      +        $originalResource = $savedService;
      +
      +        if ($volumes->count() > 0) {
      +            foreach ($volumes as $index => $volume) {
      +                $type = null;
      +                $source = null;
      +                $target = null;
      +                $content = null;
      +                $isDirectory = false;
      +                if (is_string($volume)) {
      +                    $parsed = parseDockerVolumeString($volume);
      +                    $source = $parsed['source'];
      +                    $target = $parsed['target'];
      +                    // Mode is available in $parsed['mode'] if needed
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if (sourceIsLocal($source)) {
      +                        $type = str('bind');
      +                        if ($foundConfig) {
      +                            $contentNotNull_temp = data_get($foundConfig, 'content');
      +                            if ($contentNotNull_temp) {
      +                                $content = $contentNotNull_temp;
      +                            }
      +                            $isDirectory = data_get($foundConfig, 'is_directory');
      +                        } else {
      +                            // By default, we cannot determine if the bind is a directory or not, so we set it to directory
      +                            $isDirectory = true;
      +                        }
      +                    } else {
      +                        $type = str('volume');
      +                    }
      +                } elseif (is_array($volume)) {
      +                    $type = data_get_str($volume, 'type');
      +                    $source = data_get_str($volume, 'source');
      +                    $target = data_get_str($volume, 'target');
      +                    $content = data_get($volume, 'content');
      +                    $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
      +
      +                    // Validate source and target for command injection (array/long syntax)
      +                    if ($source !== null && ! empty($source->value())) {
      +                        $sourceValue = $source->value();
      +                        // Allow simple environment variable references
      +                        $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
      +                        if (! $isSimpleEnvVar) {
      +                            try {
      +                                validateShellSafePath($sourceValue, 'volume source');
      +                            } catch (\Exception $e) {
      +                                throw new \Exception(
      +                                    'Invalid Docker volume definition (array syntax): '.$e->getMessage().
      +                                    ' Please use safe path names without shell metacharacters.'
      +                                );
      +                            }
      +                        }
      +                    }
      +                    if ($target !== null && ! empty($target->value())) {
      +                        try {
      +                            validateShellSafePath($target->value(), 'volume target');
      +                        } catch (\Exception $e) {
      +                            throw new \Exception(
      +                                'Invalid Docker volume definition (array syntax): '.$e->getMessage().
      +                                ' Please use safe path names without shell metacharacters.'
      +                            );
      +                        }
      +                    }
      +
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if ($foundConfig) {
      +                        $contentNotNull_temp = data_get($foundConfig, 'content');
      +                        if ($contentNotNull_temp) {
      +                            $content = $contentNotNull_temp;
      +                        }
      +                        $isDirectory = data_get($foundConfig, 'is_directory');
      +                    } else {
      +                        // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
      +                        if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
      +                            $isDirectory = true;
      +                        }
      +                    }
      +                }
      +                if ($type->value() === 'bind') {
      +                    if ($source->value() === '/var/run/docker.sock') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } else {
      +                        if ((int) $resource->compose_parsing_version >= 4) {
      +                            $mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
      +                        } else {
      +                            $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
      +                        }
      +                        $source = replaceLocalSource($source, $mainDirectory);
      +                        LocalFileVolume::updateOrCreate(
      +                            [
      +                                'mount_path' => $target,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ],
      +                            [
      +                                'fs_path' => $source,
      +                                'mount_path' => $target,
      +                                'content' => $content,
      +                                'is_directory' => $isDirectory,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ]
      +                        );
      +                        if (isDev()) {
      +                            if ((int) $resource->compose_parsing_version >= 4) {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid);
      +                            } else {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
      +                            }
      +                        }
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    }
      +                } elseif ($type->value() === 'volume') {
      +                    if ($topLevel->get('volumes')->has($source->value())) {
      +                        $temp = $topLevel->get('volumes')->get($source->value());
      +                        if (data_get($temp, 'driver_opts.type') === 'cifs') {
      +                            continue;
      +                        }
      +                        if (data_get($temp, 'driver_opts.type') === 'nfs') {
      +                            continue;
      +                        }
      +                    }
      +                    $slugWithoutUuid = Str::slug($source, '-');
      +                    $name = "{$uuid}_{$slugWithoutUuid}";
      +
      +                    if (is_string($volume)) {
      +                        $parsed = parseDockerVolumeString($volume);
      +                        $source = $parsed['source'];
      +                        $target = $parsed['target'];
      +                        $source = $name;
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif (is_array($volume)) {
      +                        data_set($volume, 'source', $name);
      +                    }
      +                    $topLevel->get('volumes')->put($name, [
      +                        'name' => $name,
      +                    ]);
      +                    LocalPersistentVolume::updateOrCreate(
      +                        [
      +                            'name' => $name,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ],
      +                        [
      +                            'name' => $name,
      +                            'mount_path' => $target,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ]
      +                    );
      +                }
      +                dispatch(new ServerFilesFromServerJob($originalResource));
      +                $volumesParsed->put($index, $volume);
      +            }
      +        }
      +
      +        if (! $use_network_mode) {
      +            if ($topLevel->get('networks')?->count() > 0) {
      +                foreach ($topLevel->get('networks') as $networkName => $network) {
      +                    if ($networkName === 'default') {
      +                        continue;
      +                    }
      +                    // ignore aliases
      +                    if ($network['aliases'] ?? false) {
      +                        continue;
      +                    }
      +                    $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
      +                        return $value == $networkName || $key == $networkName;
      +                    });
      +                    if (! $networkExists) {
      +                        $networks->put($networkName, null);
      +                    }
      +                }
      +            }
      +            $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
      +                return $value == $baseNetwork;
      +            });
      +            if (! $baseNetworkExists) {
      +                foreach ($baseNetwork as $network) {
      +                    $topLevel->get('networks')->put($network, [
      +                        'name' => $network,
      +                        'external' => true,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        // Collect/create/update ports
      +        $collectedPorts = collect([]);
      +        if ($ports->count() > 0) {
      +            foreach ($ports as $sport) {
      +                if (is_string($sport) || is_numeric($sport)) {
      +                    $collectedPorts->push($sport);
      +                }
      +                if (is_array($sport)) {
      +                    $target = data_get($sport, 'target');
      +                    $published = data_get($sport, 'published');
      +                    $protocol = data_get($sport, 'protocol');
      +                    $collectedPorts->push("$target:$published/$protocol");
      +                }
      +            }
      +        }
      +        $originalResource->ports = $collectedPorts->implode(',');
      +        $originalResource->save();
      +
      +        $networks_temp = collect();
      +
      +        if (! $use_network_mode) {
      +            foreach ($networks as $key => $network) {
      +                if (gettype($network) === 'string') {
      +                    // networks:
      +                    //  - appwrite
      +                    $networks_temp->put($network, null);
      +                } elseif (gettype($network) === 'array') {
      +                    // networks:
      +                    //   default:
      +                    //     ipv4_address: 192.168.203.254
      +                    $networks_temp->put($key, $network);
      +                }
      +            }
      +            foreach ($baseNetwork as $key => $network) {
      +                $networks_temp->put($network, null);
      +            }
      +        }
      +
      +        $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
      +        $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
      +            return ! str($value)->startsWith('SERVICE_');
      +        });
      +        foreach ($normalEnvironments as $key => $value) {
      +            $key = str($key);
      +            $value = str($value);
      +            $originalValue = $value;
      +            $parsedValue = replaceVariables($value);
      +            if ($parsedValue->startsWith('SERVICE_')) {
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +
      +                continue;
      +            }
      +            if (! $value->startsWith('$')) {
      +                continue;
      +            }
      +            if ($key->value() === $parsedValue->value()) {
      +                $value = null;
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +            } else {
      +                if ($value->startsWith('$')) {
      +                    $isRequired = false;
      +                    if ($value->contains(':-')) {
      +                        $value = replaceVariables($value);
      +                        $key = $value->before(':');
      +                        $value = $value->after(':-');
      +                    } elseif ($value->contains('-')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('-');
      +                        $value = $value->after('-');
      +                    } elseif ($value->contains(':?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before(':');
      +                        $value = $value->after(':?');
      +                        $isRequired = true;
      +                    } elseif ($value->contains('?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('?');
      +                        $value = $value->after('?');
      +                        $isRequired = true;
      +                    }
      +                    if ($originalValue->value() === $value->value()) {
      +                        // This means the variable does not have a default value, so it needs to be created in Coolify
      +                        $parsedKeyValue = replaceVariables($value);
      +                        $resource->environment_variables()->firstOrCreate([
      +                            'key' => $parsedKeyValue,
      +                            'resourceable_type' => get_class($resource),
      +                            'resourceable_id' => $resource->id,
      +                        ], [
      +                            'is_preview' => false,
      +                            'is_required' => $isRequired,
      +                        ]);
      +                        // Add the variable to the environment so it will be shown in the deployable compose file
      +                        $environment[$parsedKeyValue->value()] = $value;
      +
      +                        continue;
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key,
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                        'is_required' => $isRequired,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        // Add COOLIFY_RESOURCE_UUID to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
      +        }
      +
      +        // Add COOLIFY_CONTAINER_NAME to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
      +        }
      +
      +        if ($savedService->serviceType()) {
      +            $fqdns = generateServiceSpecificFqdns($savedService);
      +        } else {
      +            $fqdns = collect(data_get($savedService, 'fqdns'))->filter();
      +        }
      +
      +        $defaultLabels = defaultLabels(
      +            id: $resource->id,
      +            name: $containerName,
      +            projectName: $resource->project()->name,
      +            resourceName: $resource->name,
      +            type: 'service',
      +            subType: $isDatabase ? 'database' : 'application',
      +            subId: $savedService->id,
      +            subName: $savedService->human_name ?? $savedService->name,
      +            environment: $resource->environment->name,
      +        );
      +
      +        // Add COOLIFY_FQDN & COOLIFY_URL to environment
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
      +                return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
      +            });
      +            $coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(','));
      +            $urls = $fqdns->map(function ($fqdn): Stringable {
      +                return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
      +            });
      +            $coolifyEnvironments->put('COOLIFY_URL', $urls->implode(','));
      +        }
      +        add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
      +        if ($environment->count() > 0) {
      +            $environment = $environment->filter(function ($value, $key) {
      +                return ! str($key)->startsWith('SERVICE_FQDN_');
      +            })->map(function ($value, $key) use ($resource) {
      +                // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
      +                if (str($value)->isEmpty()) {
      +                    if ($resource->environment_variables()->where('key', $key)->exists()) {
      +                        $value = $resource->environment_variables()->where('key', $key)->first()->value;
      +                    } else {
      +                        $value = null;
      +                    }
      +                }
      +
      +                return $value;
      +            });
      +        }
      +        $serviceLabels = $labels->merge($defaultLabels);
      +        if ($serviceLabels->count() > 0) {
      +            $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled');
      +            if ($isContainerLabelEscapeEnabled) {
      +                $serviceLabels = $serviceLabels->map(function ($value, $key) {
      +                    return escapeDollarSign($value);
      +                });
      +            }
      +        }
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
      +            $uuid = $resource->uuid;
      +            $network = data_get($resource, 'destination.network');
      +            if ($shouldGenerateLabelsExactly) {
      +                switch ($server->proxyType()) {
      +                    case ProxyTypes::TRAEFIK->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image
      +                        ));
      +                        break;
      +                    case ProxyTypes::CADDY->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                            network: $network,
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image,
      +                            predefinedPort: $predefinedPort
      +                        ));
      +                        break;
      +                }
      +            } else {
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image
      +                ));
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                    network: $network,
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image,
      +                    predefinedPort: $predefinedPort
      +                ));
      +            }
      +        }
      +        if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
      +            $savedService->update(['exclude_from_status' => true]);
      +        }
      +        data_forget($service, 'volumes.*.content');
      +        data_forget($service, 'volumes.*.isDirectory');
      +        data_forget($service, 'volumes.*.is_directory');
      +        data_forget($service, 'exclude_from_hc');
      +
      +        $volumesParsed = $volumesParsed->map(function ($volume) {
      +            data_forget($volume, 'content');
      +            data_forget($volume, 'is_directory');
      +            data_forget($volume, 'isDirectory');
      +
      +            return $volume;
      +        });
      +
      +        $payload = collect($service)->merge([
      +            'container_name' => $containerName,
      +            'restart' => $restart->value(),
      +            'labels' => $serviceLabels,
      +        ]);
      +        if (! $use_network_mode) {
      +            $payload['networks'] = $networks_temp;
      +        }
      +        if ($ports->count() > 0) {
      +            $payload['ports'] = $ports;
      +        }
      +        if ($volumesParsed->count() > 0) {
      +            $payload['volumes'] = $volumesParsed;
      +        }
      +        if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
      +            $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
      +        }
      +        if ($logging) {
      +            $payload['logging'] = $logging;
      +        }
      +        if ($depends_on->count() > 0) {
      +            $payload['depends_on'] = $depends_on;
      +        }
      +
      +        $parsedServices->put($serviceName, $payload);
      +    }
      +    $topLevel->put('services', $parsedServices);
      +
      +    $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
      +
      +    $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
      +        return array_search($key, $customOrder);
      +    });
      +
      +    $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
      +    data_forget($resource, 'environment_variables');
      +    data_forget($resource, 'environment_variables_preview');
      +    $resource->save();
      +
      +    return $topLevel;
      +}
      diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
      index 714358e37..924bad307 100644
      --- a/bootstrap/helpers/proxy.php
      +++ b/bootstrap/helpers/proxy.php
      @@ -1,6 +1,6 @@
       isSwarm()) {
               $commands = $networks->map(function ($network) {
                   return [
      -                "echo 'Connecting coolify-proxy to $network network...'",
                       "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null",
                       "docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
                       "echo 'Successfully connected coolify-proxy to $network network.'",
      -                "echo 'Proxy started and configured successfully!'",
                   ];
               });
           } else {
               $commands = $networks->map(function ($network) {
                   return [
      -                "echo 'Connecting coolify-proxy to $network network...'",
                       "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null",
                       "docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
                       "echo 'Successfully connected coolify-proxy to $network network.'",
      -                "echo 'Proxy started and configured successfully!'",
                   ];
               });
           }
       
           return $commands->flatten();
       }
      -function generate_default_proxy_configuration(Server $server)
      +function extractCustomProxyCommands(Server $server, string $existing_config): array
      +{
      +    $custom_commands = [];
      +    $proxy_type = $server->proxyType();
      +
      +    if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) {
      +        return $custom_commands;
      +    }
      +
      +    try {
      +        $yaml = Yaml::parse($existing_config);
      +        $existing_commands = data_get($yaml, 'services.traefik.command', []);
      +
      +        if (empty($existing_commands)) {
      +            return $custom_commands;
      +        }
      +
      +        // Define default commands that Coolify generates
      +        $default_command_prefixes = [
      +            '--ping=',
      +            '--api.',
      +            '--entrypoints.http.address=',
      +            '--entrypoints.https.address=',
      +            '--entrypoints.http.http.encodequerysemicolons=',
      +            '--entryPoints.http.http2.maxConcurrentStreams=',
      +            '--entrypoints.https.http.encodequerysemicolons=',
      +            '--entryPoints.https.http2.maxConcurrentStreams=',
      +            '--entrypoints.https.http3',
      +            '--providers.file.',
      +            '--certificatesresolvers.',
      +            '--providers.docker',
      +            '--providers.swarm',
      +            '--log.level=',
      +            '--accesslog.',
      +        ];
      +
      +        // Extract commands that don't match default prefixes (these are custom)
      +        foreach ($existing_commands as $command) {
      +            $is_default = false;
      +            foreach ($default_command_prefixes as $prefix) {
      +                if (str_starts_with($command, $prefix)) {
      +                    $is_default = true;
      +                    break;
      +                }
      +            }
      +            if (! $is_default) {
      +                $custom_commands[] = $command;
      +            }
      +        }
      +    } catch (\Exception $e) {
      +        // If we can't parse the config, return empty array
      +        // Silently fail to avoid breaking the proxy regeneration
      +    }
      +
      +    return $custom_commands;
      +}
      +function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
       {
           $proxy_path = $server->proxyPath();
           $proxy_type = $server->proxyType();
      @@ -134,10 +186,16 @@ function generate_default_proxy_configuration(Server $server)
           }
       
           $array_of_networks = collect([]);
      -    $networks->map(function ($network) use ($array_of_networks) {
      +    $filtered_networks = collect([]);
      +    $networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
      +        if ($network === 'host') {
      +            return; // network-scoped alias is supported only for containers in user defined networks
      +        }
      +
               $array_of_networks[$network] = [
                   'external' => true,
               ];
      +        $filtered_networks->push($network);
           });
           if ($proxy_type === ProxyTypes::TRAEFIK->value) {
               $labels = [
      @@ -159,7 +217,7 @@ function generate_default_proxy_configuration(Server $server)
                           'extra_hosts' => [
                               'host.docker.internal:host-gateway',
                           ],
      -                    'networks' => $networks->toArray(),
      +                    'networks' => $filtered_networks->toArray(),
                           'ports' => [
                               '80:80',
                               '443:443',
      @@ -226,6 +284,13 @@ function generate_default_proxy_configuration(Server $server)
                   $config['services']['traefik']['command'][] = '--providers.docker=true';
                   $config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false';
               }
      +
      +        // Append custom commands (e.g., trustedIPs for Cloudflare)
      +        if (! empty($custom_commands)) {
      +            foreach ($custom_commands as $custom_command) {
      +                $config['services']['traefik']['command'][] = $custom_command;
      +            }
      +        }
           } elseif ($proxy_type === 'CADDY') {
               $config = [
                   'networks' => $array_of_networks->toArray(),
      @@ -241,7 +306,7 @@ function generate_default_proxy_configuration(Server $server)
                               'CADDY_DOCKER_POLLING_INTERVAL=5s',
                               'CADDY_DOCKER_CADDYFILE_PATH=/dynamic/Caddyfile',
                           ],
      -                    'networks' => $networks->toArray(),
      +                    'networks' => $filtered_networks->toArray(),
                           'ports' => [
                               '80:80',
                               '443:443',
      @@ -265,7 +330,7 @@ function generate_default_proxy_configuration(Server $server)
           }
       
           $config = Yaml::dump($config, 12, 2);
      -    SaveConfiguration::run($server, $config);
      +    SaveProxyConfiguration::run($server, $config);
       
           return $config;
       }
      diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
      index d1cb93d9a..3218bf878 100644
      --- a/bootstrap/helpers/remoteProcess.php
      +++ b/bootstrap/helpers/remoteProcess.php
      @@ -60,15 +60,28 @@ function remote_process(
       
       function instant_scp(string $source, string $dest, Server $server, $throwError = true)
       {
      -    $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
      -    $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
      -    $output = trim($process->output());
      -    $exitCode = $process->exitCode();
      -    if ($exitCode !== 0) {
      -        return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
      -    }
      +    return \App\Helpers\SshRetryHandler::retry(
      +        function () use ($source, $dest, $server) {
      +            $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
      +            $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
       
      -    return $output === 'null' ? null : $output;
      +            $output = trim($process->output());
      +            $exitCode = $process->exitCode();
      +
      +            if ($exitCode !== 0) {
      +                excludeCertainErrors($process->errorOutput(), $exitCode);
      +            }
      +
      +            return $output === 'null' ? null : $output;
      +        },
      +        [
      +            'server' => $server->ip,
      +            'source' => $source,
      +            'dest' => $dest,
      +            'function' => 'instant_scp',
      +        ],
      +        $throwError
      +    );
       }
       
       function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
      @@ -79,47 +92,65 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
           }
           $command_string = implode("\n", $command);
       
      -    // $start_time = microtime(true);
      -    $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      -    $process = Process::timeout(30)->run($sshCommand);
      -    // $end_time = microtime(true);
      +    return \App\Helpers\SshRetryHandler::retry(
      +        function () use ($server, $command_string) {
      +            $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      +            $process = Process::timeout(30)->run($sshCommand);
       
      -    // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
      -    // ray('SSH command execution time:', $execution_time.' ms')->orange();
      +            $output = trim($process->output());
      +            $exitCode = $process->exitCode();
       
      -    $output = trim($process->output());
      -    $exitCode = $process->exitCode();
      +            if ($exitCode !== 0) {
      +                excludeCertainErrors($process->errorOutput(), $exitCode);
      +            }
       
      -    if ($exitCode !== 0) {
      -        return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
      -    }
      +            // Sanitize output to ensure valid UTF-8 encoding
      +            $output = $output === 'null' ? null : sanitize_utf8_text($output);
       
      -    return $output === 'null' ? null : $output;
      +            return $output;
      +        },
      +        [
      +            'server' => $server->ip,
      +            'command_preview' => substr($command_string, 0, 100),
      +            'function' => 'instant_remote_process_with_timeout',
      +        ],
      +        $throwError
      +    );
       }
      +
       function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
       {
           $command = $command instanceof Collection ? $command->toArray() : $command;
      +
           if ($server->isNonRoot() && ! $no_sudo) {
               $command = parseCommandsByLineForSudo(collect($command), $server);
           }
           $command_string = implode("\n", $command);
       
      -    // $start_time = microtime(true);
      -    $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      -    $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
      -    // $end_time = microtime(true);
      +    return \App\Helpers\SshRetryHandler::retry(
      +        function () use ($server, $command_string) {
      +            $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      +            $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
       
      -    // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
      -    // ray('SSH command execution time:', $execution_time.' ms')->orange();
      +            $output = trim($process->output());
      +            $exitCode = $process->exitCode();
       
      -    $output = trim($process->output());
      -    $exitCode = $process->exitCode();
      +            if ($exitCode !== 0) {
      +                excludeCertainErrors($process->errorOutput(), $exitCode);
      +            }
       
      -    if ($exitCode !== 0) {
      -        return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
      -    }
      +            // Sanitize output to ensure valid UTF-8 encoding
      +            $output = $output === 'null' ? null : sanitize_utf8_text($output);
       
      -    return $output === 'null' ? null : $output;
      +            return $output;
      +        },
      +        [
      +            'server' => $server->ip,
      +            'command_preview' => substr($command_string, 0, 100),
      +            'function' => 'instant_remote_process',
      +        ],
      +        $throwError
      +    );
       }
       
       function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
      @@ -129,11 +160,18 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
               'Could not resolve hostname',
           ]);
           $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error));
      +
      +    // Ensure we always have a meaningful error message
      +    $errorMessage = trim($errorOutput);
      +    if (empty($errorMessage)) {
      +        $errorMessage = "SSH command failed with exit code: $exitCode";
      +    }
      +
           if ($ignored) {
               // TODO: Create new exception and disable in sentry
      -        throw new \RuntimeException($errorOutput, $exitCode);
      +        throw new \RuntimeException($errorMessage, $exitCode);
           }
      -    throw new \RuntimeException($errorOutput, $exitCode);
      +    throw new \RuntimeException($errorMessage, $exitCode);
       }
       
       function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection
      @@ -143,15 +181,38 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
           }
           $application = Application::find(data_get($application_deployment_queue, 'application_id'));
           $is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
      +
      +    $logs = data_get($application_deployment_queue, 'logs');
      +    if (empty($logs)) {
      +        return collect([]);
      +    }
      +
           try {
               $decoded = json_decode(
      -            data_get($application_deployment_queue, 'logs'),
      +            $logs,
                   associative: true,
                   flags: JSON_THROW_ON_ERROR
               );
      -    } catch (\JsonException) {
      +    } catch (\JsonException $e) {
      +        // If JSON decoding fails, try to clean up the logs and retry
      +        try {
      +            // Ensure valid UTF-8 encoding
      +            $cleaned_logs = sanitize_utf8_text($logs);
      +            $decoded = json_decode(
      +                $cleaned_logs,
      +                associative: true,
      +                flags: JSON_THROW_ON_ERROR
      +            );
      +        } catch (\JsonException $e) {
      +            // If it still fails, return empty collection to prevent crashes
      +            return collect([]);
      +        }
      +    }
      +
      +    if (! is_array($decoded)) {
               return collect([]);
           }
      +
           $seenCommands = collect();
           $formatted = collect($decoded);
           if (! $is_debug_enabled) {
      @@ -204,11 +265,41 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
       
       function remove_iip($text)
       {
      +    // Ensure the input is valid UTF-8 before processing
      +    $text = sanitize_utf8_text($text);
      +
           $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
       
           return preg_replace('/\x1b\[[0-9;]*m/', '', $text);
       }
       
      +/**
      + * Sanitizes text to ensure it contains valid UTF-8 encoding.
      + *
      + * This function is crucial for preventing "Malformed UTF-8 characters" errors
      + * that can occur when Docker build output contains binary data mixed with text,
      + * especially during image processing or builds with many assets.
      + *
      + * @param  string|null  $text  The text to sanitize
      + * @return string Valid UTF-8 encoded text
      + */
      +function sanitize_utf8_text(?string $text): string
      +{
      +    if (empty($text)) {
      +        return '';
      +    }
      +
      +    // Convert to UTF-8, replacing invalid sequences
      +    $sanitized = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
      +
      +    // Additional fallback: use SUBSTITUTE flag to replace invalid sequences with substitution character
      +    if (! mb_check_encoding($sanitized, 'UTF-8')) {
      +        $sanitized = mb_convert_encoding($text, 'UTF-8', mb_detect_encoding($text, mb_detect_order(), true) ?: 'UTF-8');
      +    }
      +
      +    return $sanitized;
      +}
      +
       function refresh_server_connection(?PrivateKey $private_key = null)
       {
           if (is_null($private_key)) {
      diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
      index cd99713a2..a124272a2 100644
      --- a/bootstrap/helpers/services.php
      +++ b/bootstrap/helpers/services.php
      @@ -1,7 +1,6 @@
       image = $updatedImage;
                   $resource->save();
               }
      +
      +        $serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
      +        $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
      +        $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
      +
               if ($resource->fqdn) {
                   $resourceFqdns = str($resource->fqdn)->explode(',');
      -            if ($resourceFqdns->count() === 1) {
      -                $resourceFqdns = $resourceFqdns->first();
      -                $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '');
      -                $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                    ->where('resourceable_id', $resource->service_id)
      -                    ->where('key', $variableName)
      -                    ->first();
      -                $fqdn = Url::fromString($resourceFqdns);
      -                $port = $fqdn->getPort();
      -                $path = $fqdn->getPath();
      -                $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
      -                if ($generatedEnv) {
      -                    if ($path === '/') {
      -                        $generatedEnv->value = $fqdn;
      -                    } else {
      -                        $generatedEnv->value = $fqdn.$path;
      -                    }
      -                    $generatedEnv->save();
      -                }
      -                if ($port) {
      -                    $variableName = $variableName."_$port";
      -                    $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                        ->where('resourceable_id', $resource->service_id)
      -                        ->where('key', $variableName)
      -                        ->first();
      -                    if ($generatedEnv) {
      -                        if ($path === '/') {
      -                            $generatedEnv->value = $fqdn;
      -                        } else {
      -                            $generatedEnv->value = $fqdn.$path;
      -                        }
      -                        $generatedEnv->save();
      -                    }
      -                }
      -                $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '');
      -                $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                    ->where('resourceable_id', $resource->service_id)
      -                    ->where('key', $variableName)
      -                    ->first();
      -                $url = Url::fromString($fqdn);
      -                $port = $url->getPort();
      -                $path = $url->getPath();
      -                $url = $url->getHost();
      -                if ($generatedEnv) {
      -                    $url = str($fqdn)->after('://');
      -                    if ($path === '/') {
      -                        $generatedEnv->value = $url;
      -                    } else {
      -                        $generatedEnv->value = $url.$path;
      -                    }
      -                    $generatedEnv->save();
      -                }
      -                if ($port) {
      -                    $variableName = $variableName."_$port";
      -                    $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                        ->where('resourceable_id', $resource->service_id)
      -                        ->where('key', $variableName)
      -                        ->first();
      -                    if ($generatedEnv) {
      -                        if ($path === '/') {
      -                            $generatedEnv->value = $url;
      -                        } else {
      -                            $generatedEnv->value = $url.$path;
      -                        }
      -                        $generatedEnv->save();
      -                    }
      -                }
      -            } elseif ($resourceFqdns->count() > 1) {
      -                foreach ($resourceFqdns as $fqdn) {
      -                    $host = Url::fromString($fqdn);
      -                    $port = $host->getPort();
      -                    $url = $host->getHost();
      -                    $path = $host->getPath();
      -                    $host = $host->getScheme().'://'.$host->getHost();
      -                    if ($port) {
      -                        $port_envs = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', 'like', "SERVICE_FQDN_%_$port")
      -                            ->get();
      -                        foreach ($port_envs as $port_env) {
      -                            $service_fqdn = str($port_env->key)->beforeLast('_')->after('SERVICE_FQDN_');
      -                            $env = EnvironmentVariable::where('resourceable_type', Service::class)
      -                                ->where('resourceable_id', $resource->service_id)
      -                                ->where('key', 'SERVICE_FQDN_'.$service_fqdn)
      -                                ->first();
      -                            if ($env) {
      -                                if ($path === '/') {
      -                                    $env->value = $host;
      -                                } else {
      -                                    $env->value = $host.$path;
      -                                }
      -                                $env->save();
      -                            }
      -                            if ($path === '/') {
      -                                $port_env->value = $host;
      -                            } else {
      -                                $port_env->value = $host.$path;
      -                            }
      -                            $port_env->save();
      -                        }
      -                        $port_envs_url = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', 'like', "SERVICE_URL_%_$port")
      -                            ->get();
      -                        foreach ($port_envs_url as $port_env_url) {
      -                            $service_url = str($port_env_url->key)->beforeLast('_')->after('SERVICE_URL_');
      -                            $env = EnvironmentVariable::where('resourceable_type', Service::class)
      -                                ->where('resourceable_id', $resource->service_id)
      -                                ->where('key', 'SERVICE_URL_'.$service_url)
      -                                ->first();
      -                            if ($env) {
      -                                if ($path === '/') {
      -                                    $env->value = $url;
      -                                } else {
      -                                    $env->value = $url.$path;
      -                                }
      -                                $env->save();
      -                            }
      -                            if ($path === '/') {
      -                                $port_env_url->value = $url;
      -                            } else {
      -                                $port_env_url->value = $url.$path;
      -                            }
      -                            $port_env_url->save();
      -                        }
      -                    } else {
      -                        $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '');
      -                        $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', $variableName)
      -                            ->first();
      -                        $fqdn = Url::fromString($fqdn);
      -                        $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost().$fqdn->getPath();
      -                        if ($generatedEnv) {
      -                            $generatedEnv->value = $fqdn;
      -                            $generatedEnv->save();
      -                        }
      -                        $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '');
      -                        $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', $variableName)
      -                            ->first();
      -                        $url = Url::fromString($fqdn);
      -                        $url = $url->getHost().$url->getPath();
      -                        if ($generatedEnv) {
      -                            $url = str($fqdn)->after('://');
      -                            $generatedEnv->value = $url;
      -                            $generatedEnv->save();
      -                        }
      -                    }
      -                }
      +            $resourceFqdns = $resourceFqdns->first();
      +            $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
      +            $url = Url::fromString($resourceFqdns);
      +            $port = $url->getPort();
      +            $path = $url->getPath();
      +            $urlValue = $url->getScheme().'://'.$url->getHost();
      +            $urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
      +            $resource->service->environment_variables()->updateOrCreate([
      +                'resourceable_type' => Service::class,
      +                'resourceable_id' => $resource->service_id,
      +                'key' => $variableName,
      +            ], [
      +                'value' => $urlValue,
      +                'is_preview' => false,
      +            ]);
      +            if ($port) {
      +                $variableName = $variableName."_$port";
      +                $resource->service->environment_variables()->updateOrCreate([
      +                    'resourceable_type' => Service::class,
      +                    'resourceable_id' => $resource->service_id,
      +                    'key' => $variableName,
      +                ], [
      +                    'value' => $urlValue,
      +                    'is_preview' => false,
      +                ]);
      +            }
      +            $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
      +            $fqdn = Url::fromString($resourceFqdns);
      +            $port = $fqdn->getPort();
      +            $path = $fqdn->getPath();
      +            $fqdn = $fqdn->getHost();
      +            $fqdnValue = str($fqdn)->after('://');
      +            if ($path !== '/') {
      +                $fqdnValue = $fqdnValue.$path;
      +            }
      +            $resource->service->environment_variables()->updateOrCreate([
      +                'resourceable_type' => Service::class,
      +                'resourceable_id' => $resource->service_id,
      +                'key' => $variableName,
      +            ], [
      +                'value' => $fqdnValue,
      +                'is_preview' => false,
      +            ]);
      +            if ($port) {
      +                $variableName = $variableName."_$port";
      +                $resource->service->environment_variables()->updateOrCreate([
      +                    'resourceable_type' => Service::class,
      +                    'resourceable_id' => $resource->service_id,
      +                    'key' => $variableName,
      +                ], [
      +                    'value' => $fqdnValue,
      +                    'is_preview' => false,
      +                ]);
                   }
               }
           } catch (\Throwable $e) {
      diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
      index 7d6d55c5d..0f5b6f553 100644
      --- a/bootstrap/helpers/shared.php
      +++ b/bootstrap/helpers/shared.php
      @@ -104,6 +104,48 @@ function sanitize_string(?string $input = null): ?string
           return $sanitized;
       }
       
      +/**
      + * Validate that a path or identifier is safe for use in shell commands.
      + *
      + * This function prevents command injection by rejecting strings that contain
      + * shell metacharacters or command substitution patterns.
      + *
      + * @param  string  $input  The path or identifier to validate
      + * @param  string  $context  Descriptive name for error messages (e.g., 'volume source', 'service name')
      + * @return string The validated input (unchanged if valid)
      + *
      + * @throws \Exception If dangerous characters are detected
      + */
      +function validateShellSafePath(string $input, string $context = 'path'): string
      +{
      +    // List of dangerous shell metacharacters that enable command injection
      +    $dangerousChars = [
      +        '`' => 'backtick (command substitution)',
      +        '$(' => 'command substitution',
      +        '${' => 'variable substitution with potential command injection',
      +        '|' => 'pipe operator',
      +        '&' => 'background/AND operator',
      +        ';' => 'command separator',
      +        "\n" => 'newline (command separator)',
      +        "\r" => 'carriage return',
      +        "\t" => 'tab (token separator)',
      +        '>' => 'output redirection',
      +        '<' => 'input redirection',
      +    ];
      +
      +    // Check for dangerous characters
      +    foreach ($dangerousChars as $char => $description) {
      +        if (str_contains($input, $char)) {
      +            throw new \Exception(
      +                "Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
      +                'Shell metacharacters are not allowed for security reasons.'
      +            );
      +        }
      +    }
      +
      +    return $input;
      +}
      +
       function generate_readme_file(string $name, string $updated_at): string
       {
           $name = sanitize_string($name);
      @@ -204,7 +246,6 @@ function get_latest_version_of_coolify(): string
       
               return data_get($versions, 'coolify.v4.version');
           } catch (\Throwable $e) {
      -        ray($e->getMessage());
       
               return '0.0.0';
           }
      @@ -402,7 +443,7 @@ function data_get_str($data, $key, $default = null): Stringable
           return str($str);
       }
       
      -function generateFqdn(Server $server, string $random, bool $forceHttps = false): string
      +function generateUrl(Server $server, string $random, bool $forceHttps = false): string
       {
           $wildcard = data_get($server, 'settings.wildcard_domain');
           if (is_null($wildcard) || $wildcard === '') {
      @@ -418,6 +459,27 @@ function generateFqdn(Server $server, string $random, bool $forceHttps = false):
       
           return "$scheme://{$random}.$host$path";
       }
      +function generateFqdn(Server $server, string $random, bool $forceHttps = false, int $parserVersion = 5): string
      +{
      +
      +    $wildcard = data_get($server, 'settings.wildcard_domain');
      +    if (is_null($wildcard) || $wildcard === '') {
      +        $wildcard = sslip($server);
      +    }
      +    $url = Url::fromString($wildcard);
      +    $host = $url->getHost();
      +    $path = $url->getPath() === '/' ? '' : $url->getPath();
      +    $scheme = $url->getScheme();
      +    if ($forceHttps) {
      +        $scheme = 'https';
      +    }
      +
      +    if ($parserVersion >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
      +        return "{$random}.$host$path";
      +    }
      +
      +    return "$scheme://{$random}.$host$path";
      +}
       function sslip(Server $server)
       {
           if (isDev() && $server->id === 0) {
      @@ -451,12 +513,12 @@ function get_service_templates(bool $force = false): Collection
       
                   return collect($services);
               } catch (\Throwable) {
      -            $services = File::get(base_path('templates/service-templates.json'));
      +            $services = File::get(base_path('templates/'.config('constants.services.file_name')));
       
                   return collect(json_decode($services))->sortKeys();
               }
           } else {
      -        $services = File::get(base_path('templates/service-templates.json'));
      +        $services = File::get(base_path('templates/'.config('constants.services.file_name')));
       
               return collect(json_decode($services))->sortKeys();
           }
      @@ -599,17 +661,29 @@ function getTopLevelNetworks(Service|Application $resource)
                   try {
                       $yaml = Yaml::parse($resource->docker_compose_raw);
                   } catch (\Exception $e) {
      -                throw new \Exception($e->getMessage());
      +                // If the docker-compose.yml file is not valid, we will return the network name as the key
      +                $topLevelNetworks = collect([
      +                    $resource->uuid => [
      +                        'name' => $resource->uuid,
      +                        'external' => true,
      +                    ],
      +                ]);
      +
      +                return $topLevelNetworks->keys();
                   }
                   $services = data_get($yaml, 'services');
                   $topLevelNetworks = collect(data_get($yaml, 'networks', []));
                   $definedNetwork = collect([$resource->uuid]);
                   $services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) {
                       $serviceNetworks = collect(data_get($service, 'networks', []));
      -                $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false;
      +                $networkMode = data_get($service, 'network_mode');
       
      -                // Only add 'networks' key if 'network_mode' is not 'host'
      -                if (! $hasHostNetworkMode) {
      +                $hasValidNetworkMode =
      +                    $networkMode === 'host' ||
      +                    (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
      +
      +                // Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:'
      +                if (! $hasValidNetworkMode) {
                           // Collect/create/update networks
                           if ($serviceNetworks->count() > 0) {
                               foreach ($serviceNetworks as $networkName => $networkDetails) {
      @@ -653,9 +727,16 @@ function getTopLevelNetworks(Service|Application $resource)
               try {
                   $yaml = Yaml::parse($resource->docker_compose_raw);
               } catch (\Exception $e) {
      -            throw new \Exception($e->getMessage());
      +            // If the docker-compose.yml file is not valid, we will return the network name as the key
      +            $topLevelNetworks = collect([
      +                $resource->uuid => [
      +                    'name' => $resource->uuid,
      +                    'external' => true,
      +                ],
      +            ]);
      +
      +            return $topLevelNetworks->keys();
               }
      -        $server = $resource->destination->server;
               $topLevelNetworks = collect(data_get($yaml, 'networks', []));
               $services = data_get($yaml, 'services');
               $definedNetwork = collect([$resource->uuid]);
      @@ -926,7 +1007,7 @@ function getRealtime()
           }
       }
       
      -function validate_dns_entry(string $fqdn, Server $server)
      +function validateDNSEntry(string $fqdn, Server $server)
       {
           // https://www.cloudflare.com/ips-v4/#
           $cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
      @@ -959,7 +1040,7 @@ function validate_dns_entry(string $fqdn, Server $server)
                   } else {
                       foreach ($results as $result) {
                           if ($result->getType() == $type) {
      -                        if (ip_match($result->getData(), $cloudflare_ips->toArray(), $match)) {
      +                        if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
                                   $found_matching_ip = true;
                                   break;
                               }
      @@ -977,7 +1058,7 @@ function validate_dns_entry(string $fqdn, Server $server)
           return $found_matching_ip;
       }
       
      -function ip_match($ip, $cidrs, &$match = null)
      +function ipMatch($ip, $cidrs, &$match = null)
       {
           foreach ((array) $cidrs as $cidr) {
               [$subnet, $mask] = explode('/', $cidr);
      @@ -990,223 +1071,63 @@ function ip_match($ip, $cidrs, &$match = null)
       
           return false;
       }
      -function checkIfDomainIsAlreadyUsed(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
      +
      +function checkIPAgainstAllowlist($ip, $allowlist)
       {
      -    if (is_null($teamId)) {
      -        return response()->json(['error' => 'Team ID is required.'], 400);
      -    }
      -    if (is_array($domains)) {
      -        $domains = collect($domains);
      +    if (empty($allowlist)) {
      +        return false;
           }
       
      -    $domains = $domains->map(function ($domain) {
      -        if (str($domain)->endsWith('/')) {
      -            $domain = str($domain)->beforeLast('/');
      +    foreach ((array) $allowlist as $allowed) {
      +        $allowed = trim($allowed);
      +
      +        if (empty($allowed)) {
      +            continue;
               }
       
      -        return str($domain);
      -    });
      -    $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid']);
      -    $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid']);
      -    if ($uuid) {
      -        $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
      -        $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
      -    }
      -    $domainFound = false;
      -    foreach ($applications as $app) {
      -        if (is_null($app->fqdn)) {
      -            continue;
      -        }
      -        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      -        foreach ($list_of_domains as $domain) {
      -            if (str($domain)->endsWith('/')) {
      -                $domain = str($domain)->beforeLast('/');
      +        // Check if it's a CIDR notation
      +        if (str_contains($allowed, '/')) {
      +            [$subnet, $mask] = explode('/', $allowed);
      +
      +            // Special case: 0.0.0.0 with any subnet means allow all
      +            if ($subnet === '0.0.0.0') {
      +                return true;
                   }
      -            $naked_domain = str($domain)->value();
      -            if ($domains->contains($naked_domain)) {
      -                $domainFound = true;
      -                break;
      +
      +            $mask = (int) $mask;
      +
      +            // Validate mask
      +            if ($mask < 0 || $mask > 32) {
      +                continue;
                   }
      -        }
      -    }
      -    if ($domainFound) {
      -        return true;
      -    }
      -    foreach ($serviceApplications as $app) {
      -        if (str($app->fqdn)->isEmpty()) {
      -            continue;
      -        }
      -        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      -        foreach ($list_of_domains as $domain) {
      -            if (str($domain)->endsWith('/')) {
      -                $domain = str($domain)->beforeLast('/');
      +
      +            // Calculate network addresses
      +            $ip_long = ip2long($ip);
      +            $subnet_long = ip2long($subnet);
      +
      +            if ($ip_long === false || $subnet_long === false) {
      +                continue;
                   }
      -            $naked_domain = str($domain)->value();
      -            if ($domains->contains($naked_domain)) {
      -                $domainFound = true;
      -                break;
      +
      +            $mask_long = ~((1 << (32 - $mask)) - 1);
      +
      +            if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
      +                return true;
                   }
      -        }
      -    }
      -    if ($domainFound) {
      -        return true;
      -    }
      -    $settings = instanceSettings();
      -    if (data_get($settings, 'fqdn')) {
      -        $domain = data_get($settings, 'fqdn');
      -        if (str($domain)->endsWith('/')) {
      -            $domain = str($domain)->beforeLast('/');
      -        }
      -        $naked_domain = str($domain)->value();
      -        if ($domains->contains($naked_domain)) {
      -            return true;
      -        }
      -    }
      -}
      -function check_domain_usage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
      -{
      -    if ($resource) {
      -        if ($resource->getMorphClass() === \App\Models\Application::class && $resource->build_pack === 'dockercompose') {
      -            $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
      -            $domains = collect($domains);
               } else {
      -            $domains = collect($resource->fqdns);
      -        }
      -    } elseif ($domain) {
      -        $domains = collect($domain);
      -    } else {
      -        throw new \RuntimeException('No resource or FQDN provided.');
      -    }
      -    $domains = $domains->map(function ($domain) {
      -        if (str($domain)->endsWith('/')) {
      -            $domain = str($domain)->beforeLast('/');
      -        }
      -
      -        return str($domain);
      -    });
      -    $apps = Application::all();
      -    foreach ($apps as $app) {
      -        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      -        foreach ($list_of_domains as $domain) {
      -            if (str($domain)->endsWith('/')) {
      -                $domain = str($domain)->beforeLast('/');
      +            // Special case: 0.0.0.0 means allow all
      +            if ($allowed === '0.0.0.0') {
      +                return true;
                   }
      -            $naked_domain = str($domain)->value();
      -            if ($domains->contains($naked_domain)) {
      -                if (data_get($resource, 'uuid')) {
      -                    if ($resource->uuid !== $app->uuid) {
      -                        throw new \RuntimeException("Domain $naked_domain is already in use by another resource: 

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

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

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

      Link: {$app->service->name}"); - } - } - } - } - if ($resource) { - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $domain = data_get($settings, 'fqdn'); - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - throw new \RuntimeException("Domain $naked_domain is already in use by this Coolify instance."); - } - } - } -} -function parseCommandsByLineForSudo(Collection $commands, Server $server): array -{ - $commands = $commands->map(function ($line) { - if ( - ! str(trim($line))->startsWith([ - 'cd', - 'command', - 'echo', - 'true', - 'if', - 'fi', - ]) - ) { - return "sudo $line"; - } - - if (str(trim($line))->startsWith('if')) { - return str_replace('if', 'if sudo', $line); - } - - return $line; - }); - - $commands = $commands->map(function ($line) use ($server) { - if (Str::startsWith($line, 'sudo mkdir -p')) { - return "$line && sudo chown -R $server->user:$server->user ".Str::after($line, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($line, 'sudo mkdir -p'); - } - - return $line; - }); - - $commands = $commands->map(function ($line) { - $line = str($line); - if (str($line)->contains('$(')) { - $line = $line->replace('$(', '$(sudo '); - } - if (str($line)->contains('||')) { - $line = $line->replace('||', '|| sudo'); - } - if (str($line)->contains('&&')) { - $line = $line->replace('&&', '&& sudo'); - } - if (str($line)->contains(' | ')) { - $line = $line->replace(' | ', ' | sudo '); - } - - return $line->value(); - }); - - return $commands->toArray(); -} -function parseLineForSudo(string $command, Server $server): string -{ - if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) { - $command = "sudo $command"; - } - if (Str::startsWith($command, 'sudo mkdir -p')) { - $command = "$command && sudo chown -R $server->user:$server->user ".Str::after($command, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($command, 'sudo mkdir -p'); - } - if (str($command)->contains('$(') || str($command)->contains('`')) { - $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); - } - if (str($command)->contains('||')) { - $command = str($command)->replace('||', '|| sudo ')->value(); - } - if (str($command)->contains('&&')) { - $command = str($command)->replace('&&', '&& sudo ')->value(); - } - - return $command; + return false; } function get_public_ips() @@ -1250,30 +1171,77 @@ function get_public_ips() function isAnyDeploymentInprogress() { $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); - $basicDetails = $runningJobs->map(function ($job) { - return [ - 'id' => $job->id, - 'created_at' => $job->created_at, - 'application_id' => $job->application_id, - 'server_id' => $job->server_id, - 'horizon_job_id' => $job->horizon_job_id, - 'status' => $job->status, - ]; - }); - echo 'Running jobs: '.json_encode($basicDetails)."\n"; + + if ($runningJobs->isEmpty()) { + echo "No deployments in progress.\n"; + exit(0); + } + $horizonJobIds = []; + $deploymentDetails = []; + foreach ($runningJobs as $runningJob) { $horizonJobStatus = getJobStatus($runningJob->horizon_job_id); if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') { $horizonJobIds[] = $runningJob->horizon_job_id; + + // Get application and team information + $application = Application::find($runningJob->application_id); + $teamMembers = []; + $deploymentUrl = ''; + + if ($application) { + // Get team members through the application's project + $team = $application->team(); + if ($team) { + $teamMembers = $team->members()->pluck('email')->toArray(); + } + + // Construct the full deployment URL + if ($runningJob->deployment_url) { + $baseUrl = base_url(); + $deploymentUrl = $baseUrl.$runningJob->deployment_url; + } + } + + $deploymentDetails[] = [ + 'id' => $runningJob->id, + 'application_name' => $runningJob->application_name ?? 'Unknown', + 'server_name' => $runningJob->server_name ?? 'Unknown', + 'deployment_url' => $deploymentUrl, + 'team_members' => $teamMembers, + 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'), + 'horizon_job_id' => $runningJob->horizon_job_id, + ]; } } + if (count($horizonJobIds) === 0) { - echo "No deployments in progress.\n"; + echo "No active deployments in progress (all jobs completed or failed).\n"; exit(0); } - $horizonJobIds = collect($horizonJobIds)->unique()->toArray(); - echo 'There are '.count($horizonJobIds)." deployments in progress.\n"; + + // Display enhanced deployment information + echo "\n=== Running Deployments ===\n"; + echo 'Total active deployments: '.count($horizonJobIds)."\n\n"; + + foreach ($deploymentDetails as $index => $deployment) { + echo 'Deployment #'.($index + 1).":\n"; + echo ' Application: '.$deployment['application_name']."\n"; + echo ' Server: '.$deployment['server_name']."\n"; + echo ' Started: '.$deployment['created_at']."\n"; + if ($deployment['deployment_url']) { + echo ' URL: '.$deployment['deployment_url']."\n"; + } + if (! empty($deployment['team_members'])) { + echo ' Team members: '.implode(', ', $deployment['team_members'])."\n"; + } else { + echo " Team members: No team members found\n"; + } + echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n"; + echo "\n"; + } + exit(1); } @@ -1291,143 +1259,6 @@ function customApiValidator(Collection|array $item, array $rules) 'required' => 'This field is required.', ]); } - -function parseServiceVolumes($serviceVolumes, $resource, $topLevelVolumes, $pull_request_id = 0) -{ - $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $pull_request_id) { - $type = null; - $source = null; - $target = null; - $content = null; - $isDirectory = false; - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $foundConfig = $resource->fileStorages()->whereMountPath($target)->first(); - if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~')) { - $type = str('bind'); - if ($foundConfig) { - $contentNotNull = data_get($foundConfig, 'content'); - if ($contentNotNull) { - $content = $contentNotNull; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - // By default, we cannot determine if the bind is a directory or not, so we set it to directory - $isDirectory = true; - } - } else { - $type = str('volume'); - } - } elseif (is_array($volume)) { - $type = data_get_str($volume, 'type'); - $source = data_get_str($volume, 'source'); - $target = data_get_str($volume, 'target'); - $content = data_get($volume, 'content'); - $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); - $foundConfig = $resource->fileStorages()->whereMountPath($target)->first(); - if ($foundConfig) { - $contentNotNull = data_get($foundConfig, 'content'); - if ($contentNotNull) { - $content = $contentNotNull; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); - if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) { - // if isDirectory is not set (or false) & content is also not set, we assume it is a directory - $isDirectory = true; - } - } - } - if ($type?->value() === 'bind') { - if ($source->value() === '/var/run/docker.sock') { - return $volume; - } - if ($source->value() === '/tmp' || $source->value() === '/tmp/') { - return $volume; - } - if (get_class($resource) === \App\Models\Application::class) { - $dir = base_configuration_dir().'/applications/'.$resource->uuid; - } else { - $dir = base_configuration_dir().'/services/'.$resource->service->uuid; - } - - if ($source->startsWith('.')) { - $source = $source->replaceFirst('.', $dir); - } - if ($source->startsWith('~')) { - $source = $source->replaceFirst('~', $dir); - } - if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; - } - if (! $resource?->settings?->is_preserve_repository_enabled || $foundConfig?->is_based_on_git) { - LocalFileVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ], - [ - 'fs_path' => $source, - 'mount_path' => $target, - 'content' => $content, - 'is_directory' => $isDirectory, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ] - ); - } - } elseif ($type->value() === 'volume') { - if ($topLevelVolumes->has($source->value())) { - $v = $topLevelVolumes->get($source->value()); - if (data_get($v, 'driver_opts.type') === 'cifs') { - return $volume; - } - } - $slugWithoutUuid = Str::slug($source, '-'); - if (get_class($resource) === \App\Models\Application::class) { - $name = "{$resource->uuid}_{$slugWithoutUuid}"; - } else { - $name = "{$resource->service->uuid}_{$slugWithoutUuid}"; - } - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $source = $name; - $volume = "$source:$target"; - } elseif (is_array($volume)) { - data_set($volume, 'source', $name); - } - $topLevelVolumes->put($name, [ - 'name' => $name, - ]); - LocalPersistentVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ], - [ - 'name' => $name, - 'mount_path' => $target, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ] - ); - } - dispatch(new ServerFilesFromServerJob($resource)); - - return $volume; - }); - - return [ - 'serviceVolumes' => $serviceVolumes, - 'topLevelVolumes' => $topLevelVolumes, - ]; -} - function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null) { if ($resource->getMorphClass() === \App\Models\Service::class) { @@ -1435,7 +1266,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal try { $yaml = Yaml::parse($resource->docker_compose_raw); } catch (\Exception $e) { - throw new \Exception($e->getMessage()); + throw new \RuntimeException($e->getMessage()); } $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); @@ -1487,10 +1318,21 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceNetworks = collect(data_get($service, 'networks', [])); $serviceVariables = collect(data_get($service, 'environment', [])); $serviceLabels = collect(data_get($service, 'labels', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); + + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { + // Handle array values from YAML (e.g., "traefik.enable: true" becomes an array) + if (is_array($serviceLabel)) { + $removedLabels->put($serviceLabelName, $serviceLabel); + + return false; + } if (! str($serviceLabel)->contains('=')) { $removedLabels->put($serviceLabelName, $serviceLabel); @@ -1500,14 +1342,18 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $serviceLabel; }); foreach ($removedLabels as $removedLabelName => $removedLabel) { + // Convert array values to strings + if (is_array($removedLabel)) { + $removedLabel = (string) collect($removedLabel)->first(); + } $serviceLabels->push("$removedLabelName=$removedLabel"); } } $containerName = "$serviceName-{$resource->uuid}"; // Decide if the service is a database - $isDatabase = isDatabaseImage(data_get_str($service, 'image')); $image = data_get_str($service, 'image'); + $isDatabase = isDatabaseImage($image, $service); data_set($service, 'is_database', $isDatabase); // Create new serviceApplication or serviceDatabase @@ -1523,6 +1369,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'name' => $serviceName, 'service_id' => $resource->id, ])->first(); + if (is_null($savedService)) { + $savedService = ServiceDatabase::create([ + 'name' => $serviceName, + 'image' => $image, + 'service_id' => $resource->id, + ]); + } } } else { if ($isNew) { @@ -1536,21 +1389,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'name' => $serviceName, 'service_id' => $resource->id, ])->first(); - } - } - if (is_null($savedService)) { - if ($isDatabase) { - $savedService = ServiceDatabase::create([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); - } else { - $savedService = ServiceApplication::create([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); + if (is_null($savedService)) { + $savedService = ServiceApplication::create([ + 'name' => $serviceName, + 'image' => $image, + 'service_id' => $resource->id, + ]); + } } } @@ -1598,7 +1443,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $savedService->ports = $collectedPorts->implode(','); $savedService->save(); - if (! $hasHostNetworkMode) { + if (! $hasValidNetworkMode) { // Add Coolify specific networks $definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) { return $value == $definedNetwork; @@ -1826,7 +1671,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1906,7 +1750,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1945,7 +1788,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $generatedValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1984,7 +1826,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_id' => $resource->id, ], [ 'value' => $defaultValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2216,6 +2057,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { + // Handle array values from YAML (e.g., "traefik.enable: true" becomes an array) + if (is_array($serviceLabel)) { + $removedLabels->put($serviceLabelName, $serviceLabel); + + return false; + } if (! str($serviceLabel)->contains('=')) { $removedLabels->put($serviceLabelName, $serviceLabel); @@ -2225,6 +2072,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $serviceLabel; }); foreach ($removedLabels as $removedLabelName => $removedLabel) { + // Convert array values to strings + if (is_array($removedLabel)) { + $removedLabel = (string) collect($removedLabel)->first(); + } $serviceLabels->push("$removedLabelName=$removedLabel"); } } @@ -2248,12 +2099,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $name->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } else { if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); if ($topLevelVolumes->has($name)) { $v = $topLevelVolumes->get($name); @@ -2292,7 +2143,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $volume->before(':'); $mount = $volume->after(':'); if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } @@ -2311,7 +2162,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $source = str($source)->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2320,7 +2171,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } else { if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2372,13 +2223,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $name->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } else { if ($pull_request_id !== 0) { $uuid = $resource->uuid; - $name = $uuid."-$name-pr-$pull_request_id"; + $name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); if ($topLevelVolumes->has($name)) { $v = $topLevelVolumes->get($name); @@ -2420,7 +2271,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $volume->before(':'); $mount = $volume->after(':'); if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } @@ -2448,7 +2299,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id === 0) { $source = $uuid."-$source"; } else { - $source = $uuid."-$source-pr-$pull_request_id"; + $source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2488,13 +2339,14 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0 && count($serviceDependencies) > 0) { $serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) { - return $dependency."-pr-$pull_request_id"; + return addPreviewDeploymentSuffix($dependency, $pull_request_id); }); data_set($service, 'depends_on', $serviceDependencies->toArray()); } // Decide if the service is a database - $isDatabase = isDatabaseImage(data_get_str($service, 'image')); + $image = data_get_str($service, 'image'); + $isDatabase = isDatabaseImage($image, $service); data_set($service, 'is_database', $isDatabase); // Collect/create/update networks @@ -2674,7 +2526,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2686,7 +2537,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $generatedValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2720,20 +2570,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($foundEnv) { $defaultValue = data_get($foundEnv, 'value'); } - $isBuildTime = data_get($foundEnv, 'is_build_time', false); if ($foundEnv) { $foundEnv->update([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, - 'is_build_time' => $isBuildTime, 'value' => $defaultValue, ]); } else { EnvironmentVariable::create([ 'key' => $key, 'value' => $defaultValue, - 'is_build_time' => $isBuildTime, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2881,7 +2728,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal }); if ($pull_request_id !== 0) { $services->each(function ($service, $serviceName) use ($pull_request_id, $services) { - $services[$serviceName."-pr-$pull_request_id"] = $service; + $services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service; data_forget($services, $serviceName); }); } @@ -2902,968 +2749,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } -function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection -{ - $isApplication = $resource instanceof Application; - $isService = $resource instanceof Service; - - $uuid = data_get($resource, 'uuid'); - $compose = data_get($resource, 'docker_compose_raw'); - if (! $compose) { - return collect([]); - } - - if ($isApplication) { - $pullRequestId = $pull_request_id; - $isPullRequest = $pullRequestId == 0 ? false : true; - $server = data_get($resource, 'destination.server'); - $fileStorages = $resource->fileStorages(); - } elseif ($isService) { - $server = data_get($resource, 'server'); - $allServices = get_service_templates(); - } else { - return collect([]); - } - - try { - $yaml = Yaml::parse($compose); - } catch (\Exception) { - return collect([]); - } - - $services = data_get($yaml, 'services', collect([])); - $topLevel = collect([ - 'volumes' => collect(data_get($yaml, 'volumes', [])), - 'networks' => collect(data_get($yaml, 'networks', [])), - 'configs' => collect(data_get($yaml, 'configs', [])), - 'secrets' => collect(data_get($yaml, 'secrets', [])), - ]); - // If there are predefined volumes, make sure they are not null - if ($topLevel->get('volumes')->count() > 0) { - $temp = collect([]); - foreach ($topLevel['volumes'] as $volumeName => $volume) { - if (is_null($volume)) { - continue; - } - $temp->put($volumeName, $volume); - } - $topLevel['volumes'] = $temp; - } - // Get the base docker network - $baseNetwork = collect([$uuid]); - if ($isApplication && $isPullRequest) { - $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]); - } - - $parsedServices = collect([]); - - $allMagicEnvironments = collect([]); - foreach ($services as $serviceName => $service) { - $predefinedPort = null; - $magicEnvironments = collect([]); - $image = data_get_str($service, 'image'); - $environment = collect(data_get($service, 'environment', [])); - $buildArgs = collect(data_get($service, 'build.args', [])); - $environment = $environment->merge($buildArgs); - $isDatabase = isDatabaseImage(data_get_str($service, 'image')); - - if ($isService) { - $containerName = "$serviceName-{$resource->uuid}"; - - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - if ($isDatabase) { - $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); - if ($applicationFound) { - $savedService = $applicationFound; - // $savedService = ServiceDatabase::firstOrCreate([ - // 'name' => $applicationFound->name, - // 'image' => $applicationFound->image, - // 'service_id' => $applicationFound->service_id, - // ]); - // $applicationFound->delete(); - } else { - $savedService = ServiceDatabase::firstOrCreate([ - 'name' => $serviceName, - 'service_id' => $resource->id, - ]); - } - } else { - $savedService = ServiceApplication::firstOrCreate([ - 'name' => $serviceName, - 'service_id' => $resource->id, - ]); - } - - // Check if image changed - if ($savedService->image !== $image) { - $savedService->image = $image; - $savedService->save(); - } - } - $environment = collect(data_get($service, 'environment', [])); - $buildArgs = collect(data_get($service, 'build.args', [])); - $environment = $environment->merge($buildArgs); - - // convert environment variables to one format - $environment = convertToKeyValueCollection($environment); - - // Add Coolify defined environments - $allEnvironments = $resource->environment_variables()->get(['key', 'value']); - - $allEnvironments = $allEnvironments->mapWithKeys(function ($item) { - return [$item['key'] => $item['value']]; - }); - // filter and add magic environments - foreach ($environment as $key => $value) { - // Get all SERVICE_ variables from keys and values - $key = str($key); - $value = str($value); - - $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; - preg_match_all($regex, $value, $valueMatches); - if (count($valueMatches[1]) > 0) { - foreach ($valueMatches[1] as $match) { - $match = replaceVariables($match); - if ($match->startsWith('SERVICE_')) { - if ($magicEnvironments->has($match->value())) { - continue; - } - $magicEnvironments->put($match->value(), ''); - } - } - } - - // Get magic environments where we need to preset the FQDN - if ($key->startsWith('SERVICE_FQDN_')) { - // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 - if (substr_count(str($key)->value(), '_') === 3) { - $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); - $port = $key->afterLast('_')->value(); - } else { - $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); - $port = null; - } - if ($isApplication) { - $fqdn = generateFqdn($server, "$uuid"); - } elseif ($isService) { - if ($fqdnFor) { - $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); - } else { - $fqdn = generateFqdn($server, "{$savedService->name}-$uuid"); - } - } - - if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { - $path = $value->value(); - if ($path !== '/') { - $fqdn = "$fqdn$path"; - } - } - $fqdnWithPort = $fqdn; - if ($port) { - $fqdnWithPort = "$fqdn:$port"; - } - if ($isApplication && is_null($resource->fqdn)) { - data_forget($resource, 'environment_variables'); - data_forget($resource, 'environment_variables_preview'); - $resource->fqdn = $fqdnWithPort; - $resource->save(); - } elseif ($isService && is_null($savedService->fqdn)) { - $savedService->fqdn = $fqdnWithPort; - $savedService->save(); - } - - if (substr_count(str($key)->value(), '_') === 2) { - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } - if (substr_count(str($key)->value(), '_') === 3) { - $newKey = str($key)->beforeLast('_'); - $resource->environment_variables()->firstOrCreate([ - 'key' => $newKey->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } - } - } - - $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); - if ($magicEnvironments->count() > 0) { - foreach ($magicEnvironments as $key => $value) { - $key = str($key); - $value = replaceVariables($value); - $command = parseCommandFromMagicEnvVariable($key); - $found = $resource->environment_variables()->where('key', $key->value())->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first(); - if ($found) { - continue; - } - if ($command->value() === 'FQDN') { - $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); - if (str($fqdnFor)->contains('_')) { - $fqdnFor = str($fqdnFor)->before('_'); - } - $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } elseif ($command->value() === 'URL') { - $fqdnFor = $key->after('SERVICE_URL_')->lower()->value(); - if (str($fqdnFor)->contains('_')) { - $fqdnFor = str($fqdnFor)->before('_'); - } - $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); - $fqdn = str($fqdn)->replace('http://', '')->replace('https://', ''); - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } else { - $value = generateEnvValue($command, $resource); - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } - } - } - } - - $serviceAppsLogDrainEnabledMap = collect([]); - if ($resource instanceof Service) { - $serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) { - return $app->isLogDrainEnabled(); - }); - } - - // Parse the rest of the services - foreach ($services as $serviceName => $service) { - $image = data_get_str($service, 'image'); - $restart = data_get_str($service, 'restart', RESTART_MODE); - $logging = data_get($service, 'logging'); - - if ($server->isLogDrainEnabled()) { - if ($resource instanceof Application && $resource->isLogDrainEnabled()) { - $logging = generate_fluentd_configuration(); - } - if ($resource instanceof Service && $serviceAppsLogDrainEnabledMap->get($serviceName)) { - $logging = generate_fluentd_configuration(); - } - } - $volumes = collect(data_get($service, 'volumes', [])); - $networks = collect(data_get($service, 'networks', [])); - $use_network_mode = data_get($service, 'network_mode') !== null; - $depends_on = collect(data_get($service, 'depends_on', [])); - $labels = collect(data_get($service, 'labels', [])); - if ($labels->count() > 0) { - if (isAssociativeArray($labels)) { - $newLabels = collect([]); - $labels->each(function ($value, $key) use ($newLabels) { - $newLabels->push("$key=$value"); - }); - $labels = $newLabels; - } - } - $environment = collect(data_get($service, 'environment', [])); - $ports = collect(data_get($service, 'ports', [])); - $buildArgs = collect(data_get($service, 'build.args', [])); - $environment = $environment->merge($buildArgs); - - $environment = convertToKeyValueCollection($environment); - $coolifyEnvironments = collect([]); - - $isDatabase = isDatabaseImage(data_get_str($service, 'image')); - $volumesParsed = collect([]); - - if ($isApplication) { - $baseName = generateApplicationContainerName( - application: $resource, - pull_request_id: $pullRequestId - ); - $containerName = "$serviceName-$baseName"; - $predefinedPort = null; - } elseif ($isService) { - $containerName = "$serviceName-{$resource->uuid}"; - - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - - if ($isDatabase) { - $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first(); - if ($applicationFound) { - $savedService = $applicationFound; - // $savedService = ServiceDatabase::firstOrCreate([ - // 'name' => $applicationFound->name, - // 'image' => $applicationFound->image, - // 'service_id' => $applicationFound->service_id, - // ]); - // $applicationFound->delete(); - } else { - $savedService = ServiceDatabase::firstOrCreate([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); - } - } else { - $savedService = ServiceApplication::firstOrCreate([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); - } - $fileStorages = $savedService->fileStorages(); - if ($savedService->image !== $image) { - $savedService->image = $image; - $savedService->save(); - } - } - - $originalResource = $isApplication ? $resource : $savedService; - - if ($volumes->count() > 0) { - foreach ($volumes as $index => $volume) { - $type = null; - $source = null; - $target = null; - $content = null; - $isDirectory = false; - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $foundConfig = $fileStorages->whereMountPath($target)->first(); - if (sourceIsLocal($source)) { - $type = str('bind'); - if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - // By default, we cannot determine if the bind is a directory or not, so we set it to directory - $isDirectory = true; - } - } else { - $type = str('volume'); - } - } elseif (is_array($volume)) { - $type = data_get_str($volume, 'type'); - $source = data_get_str($volume, 'source'); - $target = data_get_str($volume, 'target'); - $content = data_get($volume, 'content'); - $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); - - $foundConfig = $fileStorages->whereMountPath($target)->first(); - if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - // if isDirectory is not set (or false) & content is also not set, we assume it is a directory - if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) { - $isDirectory = true; - } - } - } - if ($type->value() === 'bind') { - if ($source->value() === '/var/run/docker.sock') { - $volume = $source->value().':'.$target->value(); - } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') { - $volume = $source->value().':'.$target->value(); - } else { - if ((int) $resource->compose_parsing_version >= 4) { - if ($isApplication) { - $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); - } elseif ($isService) { - $mainDirectory = str(base_configuration_dir().'/services/'.$uuid); - } - } else { - $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); - } - $source = replaceLocalSource($source, $mainDirectory); - if ($isApplication && $isPullRequest) { - $source = $source."-pr-$pullRequestId"; - } - LocalFileVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ], - [ - 'fs_path' => $source, - 'mount_path' => $target, - 'content' => $content, - 'is_directory' => $isDirectory, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ] - ); - if (isDev()) { - if ((int) $resource->compose_parsing_version >= 4) { - if ($isApplication) { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } elseif ($isService) { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid); - } - } else { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } - } - $volume = "$source:$target"; - } - } elseif ($type->value() === 'volume') { - if ($topLevel->get('volumes')->has($source->value())) { - $temp = $topLevel->get('volumes')->get($source->value()); - if (data_get($temp, 'driver_opts.type') === 'cifs') { - continue; - } - if (data_get($temp, 'driver_opts.type') === 'nfs') { - continue; - } - } - $slugWithoutUuid = Str::slug($source, '-'); - $name = "{$uuid}_{$slugWithoutUuid}"; - - if ($isApplication && $isPullRequest) { - $name = "{$name}-pr-$pullRequestId"; - } - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $source = $name; - $volume = "$source:$target"; - } elseif (is_array($volume)) { - data_set($volume, 'source', $name); - } - $topLevel->get('volumes')->put($name, [ - 'name' => $name, - ]); - LocalPersistentVolume::updateOrCreate( - [ - 'name' => $name, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ], - [ - 'name' => $name, - 'mount_path' => $target, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ] - ); - } - dispatch(new ServerFilesFromServerJob($originalResource)); - $volumesParsed->put($index, $volume); - } - } - - if ($depends_on?->count() > 0) { - if ($isApplication && $isPullRequest) { - $newDependsOn = collect([]); - $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) { - if (is_numeric($condition)) { - $dependency = "$dependency-pr-$pullRequestId"; - - $newDependsOn->put($condition, $dependency); - } else { - $condition = "$condition-pr-$pullRequestId"; - $newDependsOn->put($condition, $dependency); - } - }); - $depends_on = $newDependsOn; - } - } - if (! $use_network_mode) { - if ($topLevel->get('networks')?->count() > 0) { - foreach ($topLevel->get('networks') as $networkName => $network) { - if ($networkName === 'default') { - continue; - } - // ignore aliases - if ($network['aliases'] ?? false) { - continue; - } - $networkExists = $networks->contains(function ($value, $key) use ($networkName) { - return $value == $networkName || $key == $networkName; - }); - if (! $networkExists) { - $networks->put($networkName, null); - } - } - } - $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) { - return $value == $baseNetwork; - }); - if (! $baseNetworkExists) { - foreach ($baseNetwork as $network) { - $topLevel->get('networks')->put($network, [ - 'name' => $network, - 'external' => true, - ]); - } - } - } - - // Collect/create/update ports - $collectedPorts = collect([]); - if ($ports->count() > 0) { - foreach ($ports as $sport) { - if (is_string($sport) || is_numeric($sport)) { - $collectedPorts->push($sport); - } - if (is_array($sport)) { - $target = data_get($sport, 'target'); - $published = data_get($sport, 'published'); - $protocol = data_get($sport, 'protocol'); - $collectedPorts->push("$target:$published/$protocol"); - } - } - } - if ($isService) { - $originalResource->ports = $collectedPorts->implode(','); - $originalResource->save(); - } - - $networks_temp = collect(); - - if (! $use_network_mode) { - foreach ($networks as $key => $network) { - if (gettype($network) === 'string') { - // networks: - // - appwrite - $networks_temp->put($network, null); - } elseif (gettype($network) === 'array') { - // networks: - // default: - // ipv4_address: 192.168.203.254 - $networks_temp->put($key, $network); - } - } - foreach ($baseNetwork as $key => $network) { - $networks_temp->put($network, null); - } - - if ($isApplication) { - if (data_get($resource, 'settings.connect_to_docker_network')) { - $network = $resource->destination->network; - $networks_temp->put($network, null); - $topLevel->get('networks')->put($network, [ - 'name' => $network, - 'external' => true, - ]); - } - } - } - - $normalEnvironments = $environment->diffKeys($allMagicEnvironments); - $normalEnvironments = $normalEnvironments->filter(function ($value, $key) { - return ! str($value)->startsWith('SERVICE_'); - }); - - foreach ($normalEnvironments as $key => $value) { - $key = str($key); - $value = str($value); - $originalValue = $value; - $parsedValue = replaceVariables($value); - if ($value->startsWith('$SERVICE_')) { - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - ]); - - continue; - } - if (! $value->startsWith('$')) { - continue; - } - if ($key->value() === $parsedValue->value()) { - $value = null; - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } else { - if ($value->startsWith('$')) { - $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); - - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); - - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'is_build_time' => false, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->value; - - continue; - } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); - } - } - } - if ($isApplication) { - $branch = $originalResource->git_branch; - if ($pullRequestId !== 0) { - $branch = "pull/{$pullRequestId}/head"; - } - if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\""); - } - } - - // Add COOLIFY_RESOURCE_UUID to environment - if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}"); - } - - // Add COOLIFY_CONTAINER_NAME to environment - if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}"); - } - - if ($isApplication) { - $domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]); - $fqdns = data_get($domains, "$serviceName.domain"); - if ($fqdns) { - $fqdns = str($fqdns)->explode(','); - if ($isPullRequest) { - $preview = $resource->previews()->find($preview_id); - $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); - if ($docker_compose_domains->count() > 0) { - $found_fqdn = data_get($docker_compose_domains, "$serviceName.domain"); - if ($found_fqdn) { - $fqdns = collect($found_fqdn); - } else { - $fqdns = collect([]); - } - } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); - } - } - } - - $defaultLabels = defaultLabels( - id: $resource->id, - name: $containerName, - projectName: $resource->project()->name, - resourceName: $resource->name, - pull_request_id: $pullRequestId, - type: 'application', - environment: $resource->environment->name, - ); - } elseif ($isService) { - if ($savedService->serviceType()) { - $fqdns = generateServiceSpecificFqdns($savedService); - } else { - $fqdns = collect(data_get($savedService, 'fqdns'))->filter(); - } - - $defaultLabels = defaultLabels( - id: $resource->id, - name: $containerName, - projectName: $resource->project()->name, - resourceName: $resource->name, - type: 'service', - subType: $isDatabase ? 'database' : 'application', - subId: $savedService->id, - subName: $savedService->human_name ?? $savedService->name, - environment: $resource->environment->name, - ); - } - // Add COOLIFY_FQDN & COOLIFY_URL to environment - if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - $coolifyEnvironments->put('COOLIFY_URL', $fqdns->implode(',')); - - $urls = $fqdns->map(function ($fqdn) { - return str($fqdn)->replace('http://', '')->replace('https://', ''); - }); - $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(',')); - } - add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables); - - if ($environment->count() > 0) { - $environment = $environment->filter(function ($value, $key) { - return ! str($key)->startsWith('SERVICE_FQDN_'); - })->map(function ($value, $key) use ($resource) { - // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used - if (str($value)->isEmpty()) { - if ($resource->environment_variables()->where('key', $key)->exists()) { - $value = $resource->environment_variables()->where('key', $key)->first()->value; - } else { - $value = null; - } - } - - return $value; - }); - } - $serviceLabels = $labels->merge($defaultLabels); - if ($serviceLabels->count() > 0) { - if ($isApplication) { - $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled'); - } else { - $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled'); - } - if ($isContainerLabelEscapeEnabled) { - $serviceLabels = $serviceLabels->map(function ($value, $key) { - return escapeDollarSign($value); - }); - } - } - if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - if ($isApplication) { - $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels; - $uuid = $resource->uuid; - $network = data_get($resource, 'destination.network'); - if ($isPullRequest) { - $uuid = "{$resource->uuid}-{$pullRequestId}"; - } - if ($isPullRequest) { - $network = "{$resource->destination->network}-{$pullRequestId}"; - } - } else { - $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels; - $uuid = $resource->uuid; - $network = data_get($resource, 'destination.network'); - } - if ($shouldGenerateLabelsExactly) { - switch ($server->proxyType()) { - case ProxyTypes::TRAEFIK->value: - $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image - )); - break; - case ProxyTypes::CADDY->value: - $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image, - predefinedPort: $predefinedPort - )); - break; - } - } else { - $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image - )); - $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image, - predefinedPort: $predefinedPort - )); - } - } - if ($isService) { - if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) { - $savedService->update(['exclude_from_status' => true]); - } - } - data_forget($service, 'volumes.*.content'); - data_forget($service, 'volumes.*.isDirectory'); - data_forget($service, 'volumes.*.is_directory'); - data_forget($service, 'exclude_from_hc'); - - $volumesParsed = $volumesParsed->map(function ($volume) { - data_forget($volume, 'content'); - data_forget($volume, 'is_directory'); - data_forget($volume, 'isDirectory'); - - return $volume; - }); - - $payload = collect($service)->merge([ - 'container_name' => $containerName, - 'restart' => $restart->value(), - 'labels' => $serviceLabels, - ]); - if (! $use_network_mode) { - $payload['networks'] = $networks_temp; - } - if ($ports->count() > 0) { - $payload['ports'] = $ports; - } - if ($volumesParsed->count() > 0) { - $payload['volumes'] = $volumesParsed; - } - if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { - $payload['environment'] = $environment->merge($coolifyEnvironments); - } - if ($logging) { - $payload['logging'] = $logging; - } - if ($depends_on->count() > 0) { - $payload['depends_on'] = $depends_on; - } - if ($isApplication && $isPullRequest) { - $serviceName = "{$serviceName}-pr-{$pullRequestId}"; - } - - $parsedServices->put($serviceName, $payload); - } - $topLevel->put('services', $parsedServices); - - $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets']; - - $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) { - return array_search($key, $customOrder); - }); - - $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2); - data_forget($resource, 'environment_variables'); - data_forget($resource, 'environment_variables_preview'); - $resource->save(); - - return $topLevel; -} - function generate_fluentd_configuration(): array { return [ @@ -4223,3 +3108,18 @@ function parseDockerfileInterval(string $something) return $seconds; } + +function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string +{ + return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id; +} + +function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection +{ + $collection = collect([]); + foreach ($services as $serviceName => $_) { + $collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId)); + } + + return $collection; +} diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index fe19752cb..fd3fbe74b 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -22,15 +22,26 @@ function get_socialite_provider(string $provider) return Socialite::driver('azure')->setConfig($azure_config); } - if ($provider == 'authentik') { - $authentik_config = new \SocialiteProviders\Manager\Config( + if ($provider == 'authentik' || $provider == 'clerk') { + $authentik_clerk_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, ['base_url' => $oauth_setting->base_url], ); - return Socialite::driver('authentik')->setConfig($authentik_config); + return Socialite::driver($provider)->setConfig($authentik_clerk_config); + } + + if ($provider == 'zitadel') { + $zitadel_config = new \SocialiteProviders\Manager\Config( + $oauth_setting->client_id, + $oauth_setting->client_secret, + $oauth_setting->redirect_uri, + ['base_url' => $oauth_setting->base_url], + ); + + return Socialite::driver('zitadel')->setConfig($zitadel_config); } if ($provider == 'google') { @@ -53,13 +64,20 @@ function get_socialite_provider(string $provider) $provider_class_map = [ 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, + 'discord' => \SocialiteProviders\Discord\Provider::class, 'github' => \Laravel\Socialite\Two\GithubProvider::class, 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, ]; - return Socialite::buildProvider( + $socialite = Socialite::buildProvider( $provider_class_map[$provider], $config ); + + if ($provider == 'gitlab' && ! empty($oauth_setting->base_url)) { + $socialite->setHost($oauth_setting->base_url); + } + + return $socialite; } diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 510516a2f..48c3a62c3 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts() 'livewire/update', ]; } + +function updateStripeCustomerEmail(Team $team, string $newEmail): void +{ + if (! isStripe()) { + return; + } + + $stripe_customer_id = data_get($team, 'subscription.stripe_customer_id'); + if (! $stripe_customer_id) { + return; + } + + Stripe::setApiKey(config('subscription.stripe_api_key')); + + \Stripe\Customer::update( + $stripe_customer_id, + ['email' => $newEmail] + ); +} diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php new file mode 100644 index 000000000..ba252c64f --- /dev/null +++ b/bootstrap/helpers/sudo.php @@ -0,0 +1,101 @@ +map(function ($line) { + if ( + ! str(trim($line))->startsWith([ + 'cd', + 'command', + 'echo', + 'true', + 'if', + 'fi', + ]) + ) { + return "sudo $line"; + } + + if (str(trim($line))->startsWith('if')) { + return str_replace('if', 'if sudo', $line); + } + + return $line; + }); + + $commands = $commands->map(function ($line) use ($server) { + if (Str::startsWith($line, 'sudo mkdir -p')) { + $path = trim(Str::after($line, 'sudo mkdir -p')); + if (shouldChangeOwnership($path)) { + return "$line && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path"; + } + + return $line; + } + + return $line; + }); + + $commands = $commands->map(function ($line) { + $line = str($line); + if (str($line)->contains('$(')) { + $line = $line->replace('$(', '$(sudo '); + } + if (str($line)->contains('||')) { + $line = $line->replace('||', '|| sudo'); + } + if (str($line)->contains('&&')) { + $line = $line->replace('&&', '&& sudo'); + } + if (str($line)->contains(' | ')) { + $line = $line->replace(' | ', ' | sudo '); + } + + return $line->value(); + }); + + return $commands->toArray(); +} +function parseLineForSudo(string $command, Server $server): string +{ + if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) { + $command = "sudo $command"; + } + if (Str::startsWith($command, 'sudo mkdir -p')) { + $path = trim(Str::after($command, 'sudo mkdir -p')); + if (shouldChangeOwnership($path)) { + $command = "$command && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path"; + } + } + if (str($command)->contains('$(') || str($command)->contains('`')) { + $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); + } + if (str($command)->contains('||')) { + $command = str($command)->replace('||', '|| sudo ')->value(); + } + if (str($command)->contains('&&')) { + $command = str($command)->replace('&&', '&& sudo ')->value(); + } + + return $command; +} diff --git a/changelogs/.gitignore b/changelogs/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/changelogs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/composer.json b/composer.json index 5b97e76e9..ea466049d 100644 --- a/composer.json +++ b/composer.json @@ -12,63 +12,68 @@ ], "require": { "php": "^8.4", - "danharrin/livewire-rate-limiting": "2.1.0", - "doctrine/dbal": "^4.2.2", - "guzzlehttp/guzzle": "^7.9.2", - "laravel/fortify": "^1.25.4", - "laravel/framework": "12.4.1", - "laravel/horizon": "^5.30.3", - "laravel/pail": "^1.2.2", - "laravel/prompts": "^0.3.5|^0.3.5|^0.3.5", - "laravel/sanctum": "^4.0.8", - "laravel/socialite": "^5.18.0", + "danharrin/livewire-rate-limiting": "^2.1.0", + "doctrine/dbal": "^4.3.0", + "guzzlehttp/guzzle": "^7.9.3", + "laravel/fortify": "^1.27.0", + "laravel/framework": "^12.20.0", + "laravel/horizon": "^5.33.1", + "laravel/pail": "^1.2.3", + "laravel/prompts": "^0.3.6|^0.3.6|^0.3.6", + "laravel/sanctum": "^4.1.2", + "laravel/socialite": "^5.21.0", "laravel/tinker": "^2.10.1", "laravel/ui": "^4.6.1", "lcobucci/jwt": "^5.5.0", "league/flysystem-aws-s3-v3": "^3.29", - "league/flysystem-sftp-v3": "^3.29", - "livewire/livewire": "^3.5.20", + "league/flysystem-sftp-v3": "^3.30", + "livewire/livewire": "^3.6.4", "log1x/laravel-webfonts": "^2.0.1", - "lorisleiva/laravel-actions": "^2.8.6", + "lorisleiva/laravel-actions": "^2.9.0", "nubs/random-name-generator": "^2.2", - "phpseclib/phpseclib": "^3.0.43", - "pion/laravel-chunk-upload": "^1.5.4", + "phpseclib/phpseclib": "^3.0.46", + "pion/laravel-chunk-upload": "^1.5.6", "poliander/cron": "^3.2.1", "purplepixie/phpdns": "^2.2", "pusher/pusher-php-server": "^7.2.7", - "resend/resend-laravel": "^0.17.0", - "sentry/sentry-laravel": "^4.13", + "resend/resend-laravel": "^0.19.0", + "sentry/sentry-laravel": "^4.15.1", "socialiteproviders/authentik": "^5.2", + "socialiteproviders/clerk": "^5.0", + "socialiteproviders/discord": "^4.2", "socialiteproviders/google": "^4.1", "socialiteproviders/infomaniak": "^4.0", "socialiteproviders/microsoft-azure": "^5.2", - "spatie/laravel-activitylog": "^4.10.1", - "spatie/laravel-data": "^4.13.1", - "spatie/laravel-ray": "^1.39.1", + "socialiteproviders/zitadel": "^4.2", + "spatie/laravel-activitylog": "^4.10.2", + "spatie/laravel-data": "^4.17.0", + "spatie/laravel-markdown": "^2.7", + "spatie/laravel-ray": "^1.40.2", "spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/url": "^2.4", - "stevebauman/purify": "^6.3", - "stripe/stripe-php": "^16.5.1", - "symfony/yaml": "^7.2.3", + "stevebauman/purify": "^6.3.1", + "stripe/stripe-php": "^16.6.0", + "symfony/yaml": "^7.3.1", "visus/cuid2": "^4.1.0", "yosymfony/toml": "^1.0.4", - "zircote/swagger-php": "^5.0.5" + "zircote/swagger-php": "^5.1.4" }, "require-dev": { - "barryvdh/laravel-debugbar": "^3.15.1", - "driftingly/rector-laravel": "^2.0.2", + "barryvdh/laravel-debugbar": "^3.15.4", + "driftingly/rector-laravel": "^2.0.5", "fakerphp/faker": "^1.24.1", - "laravel/dusk": "^8.3.1", - "laravel/pint": "^1.21", - "laravel/telescope": "^5.5", + "laravel/boost": "^1.1", + "laravel/dusk": "^8.3.3", + "laravel/pint": "^1.24", + "laravel/telescope": "^5.10", "mockery/mockery": "^1.6.12", - "nunomaduro/collision": "^8.6.1", - "pestphp/pest": "^3.8.0", - "phpstan/phpstan": "^2.1.6", - "rector/rector": "^2.0.9", + "nunomaduro/collision": "^8.8.2", + "pestphp/pest": "^3.8.2", + "phpstan/phpstan": "^2.1.18", + "rector/rector": "^2.1.2", "serversideup/spin": "^3.0.2", "spatie/laravel-ignition": "^2.9.1", - "symfony/http-client": "^7.2.3" + "symfony/http-client": "^7.3.1" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/composer.lock b/composer.lock index 939178d38..6320db071 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f0ef990aa6e05ab48d61fcc22c0c1ca2", + "content-hash": "a993799242581bd06b5939005ee458d9", "packages": [ { "name": "amphp/amp", @@ -870,16 +870,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.342.17", + "version": "3.352.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "110ca44672e59ec3255c693e9e761001f2706b53" + "reference": "7f3ad0da2545b24259273ea7ab892188bae7d91b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/110ca44672e59ec3255c693e9e761001f2706b53", - "reference": "110ca44672e59ec3255c693e9e761001f2706b53", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7f3ad0da2545b24259273ea7ab892188bae7d91b", + "reference": "7f3ad0da2545b24259273ea7ab892188bae7d91b", "shasum": "" }, "require": { @@ -961,9 +961,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.342.17" + "source": "https://github.com/aws/aws-sdk-php/tree/3.352.0" }, - "time": "2025-03-31T18:16:40+00:00" + "time": "2025-08-01T18:04:23+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1021,16 +1021,16 @@ }, { "name": "brick/math", - "version": "0.12.3", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { @@ -1069,7 +1069,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.13.1" }, "funding": [ { @@ -1077,7 +1077,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2025-03-29T13:50:30+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -1373,34 +1373,34 @@ }, { "name": "doctrine/dbal", - "version": "4.2.3", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "33d2d7fe1269b2301640c44cf2896ea607b30e3e" + "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/33d2d7fe1269b2301640c44cf2896ea607b30e3e", - "reference": "33d2d7fe1269b2301640c44cf2896ea607b30e3e", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/ac336c95ea9e13433d56ca81c308b39db0e1a2a7", + "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7", "shasum": "" }, "require": { - "doctrine/deprecations": "^0.5.3|^1", - "php": "^8.1", + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "12.0.0", + "doctrine/coding-standard": "13.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.2", - "phpstan/phpstan": "2.1.1", - "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan": "2.1.17", + "phpstan/phpstan-phpunit": "2.0.6", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "10.5.39", - "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.10.2", + "phpunit/phpunit": "11.5.23", + "slevomat/coding-standard": "8.16.2", + "squizlabs/php_codesniffer": "3.13.1", "symfony/cache": "^6.3.8|^7.0", "symfony/console": "^5.4|^6.3|^7.0" }, @@ -1459,7 +1459,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.2.3" + "source": "https://github.com/doctrine/dbal/tree/4.3.1" }, "funding": [ { @@ -1475,30 +1475,33 @@ "type": "tidelift" } ], - "time": "2025-03-07T18:29:05+00:00" + "time": "2025-07-22T10:09:51+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1518,9 +1521,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "doctrine/inflector", @@ -1885,16 +1888,16 @@ }, { "name": "firebase/php-jwt", - "version": "v6.11.0", + "version": "v6.11.1", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712" + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/8f718f4dfc9c5d5f0c994cdfd103921b43592712", - "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, "require": { @@ -1942,9 +1945,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.0" + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" }, - "time": "2025-01-23T05:11:06+00:00" + "time": "2025-04-09T20:32:01+00:00" }, { "name": "fruitcake/php-cors", @@ -2610,16 +2613,16 @@ }, { "name": "laravel/fortify", - "version": "v1.25.4", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "f185600e2d3a861834ad00ee3b7863f26ac25d3f" + "reference": "0fb2ec99dfee77ed66884668fc06683acca91ebd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/f185600e2d3a861834ad00ee3b7863f26ac25d3f", - "reference": "f185600e2d3a861834ad00ee3b7863f26ac25d3f", + "url": "https://api.github.com/repos/laravel/fortify/zipball/0fb2ec99dfee77ed66884668fc06683acca91ebd", + "reference": "0fb2ec99dfee77ed66884668fc06683acca91ebd", "shasum": "" }, "require": { @@ -2671,24 +2674,24 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2025-01-26T19:34:46+00:00" + "time": "2025-06-11T14:30:52+00:00" }, { "name": "laravel/framework", - "version": "v12.4.1", + "version": "v12.21.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "cdefd852ecb459a65392cd6ccb578c92a15b8e2b" + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/cdefd852ecb459a65392cd6ccb578c92a15b8e2b", - "reference": "cdefd852ecb459a65392cd6ccb578c92a15b8e2b", + "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -2705,7 +2708,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.6", + "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -2793,11 +2796,11 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "orchestra/testbench-core": "^10.0.0", - "pda/pheanstalk": "^5.0.6", + "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3", + "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", @@ -2829,7 +2832,7 @@ "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", @@ -2886,20 +2889,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-30T16:27:26+00:00" + "time": "2025-07-22T15:41:55+00:00" }, { "name": "laravel/horizon", - "version": "v5.31.0", + "version": "v5.33.1", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "a21e7d64784b24eaf3bf873f82affbf67707a72a" + "reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/a21e7d64784b24eaf3bf873f82affbf67707a72a", - "reference": "a21e7d64784b24eaf3bf873f82affbf67707a72a", + "url": "https://api.github.com/repos/laravel/horizon/zipball/50057bca1f1dcc9fbd5ff6d65143833babd784b3", + "reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3", "shasum": "" }, "require": { @@ -2939,7 +2942,7 @@ ] }, "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -2964,22 +2967,22 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.31.0" + "source": "https://github.com/laravel/horizon/tree/v5.33.1" }, - "time": "2025-03-04T14:56:42+00:00" + "time": "2025-06-16T13:48:30+00:00" }, { "name": "laravel/pail", - "version": "v1.2.2", + "version": "v1.2.3", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2" + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/f31f4980f52be17c4667f3eafe034e6826787db2", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2", + "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", "shasum": "" }, "require": { @@ -2999,7 +3002,7 @@ "orchestra/testbench-core": "^8.13|^9.0|^10.0", "pestphp/pest": "^2.20|^3.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^1.12.27", "symfony/var-dumper": "^6.3|^7.0" }, "type": "library", @@ -3035,6 +3038,7 @@ "description": "Easily delve into your Laravel application's log files directly from the command line.", "homepage": "https://github.com/laravel/pail", "keywords": [ + "dev", "laravel", "logs", "php", @@ -3044,20 +3048,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-01-28T15:15:15+00:00" + "time": "2025-06-05T13:55:57+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.5", + "version": "v0.3.6", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1" + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", "shasum": "" }, "require": { @@ -3101,22 +3105,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.5" + "source": "https://github.com/laravel/prompts/tree/v0.3.6" }, - "time": "2025-02-11T13:34:40+00:00" + "time": "2025-07-07T14:17:42+00:00" }, { "name": "laravel/sanctum", - "version": "v4.0.8", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "ec1dd9ddb2ab370f79dfe724a101856e0963f43c" + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/ec1dd9ddb2ab370f79dfe724a101856e0963f43c", - "reference": "ec1dd9ddb2ab370f79dfe724a101856e0963f43c", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", "shasum": "" }, "require": { @@ -3167,7 +3171,7 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-01-26T19:34:36+00:00" + "time": "2025-07-09T19:45:24+00:00" }, { "name": "laravel/serializable-closure", @@ -3232,16 +3236,16 @@ }, { "name": "laravel/socialite", - "version": "v5.18.0", + "version": "v5.23.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "7809dc71250e074cd42970f0f803f2cddc04c5de" + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/7809dc71250e074cd42970f0f803f2cddc04c5de", - "reference": "7809dc71250e074cd42970f0f803f2cddc04c5de", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", "shasum": "" }, "require": { @@ -3258,7 +3262,7 @@ "require-dev": { "mockery/mockery": "^1.0", "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^1.12.23", "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5" }, "type": "library", @@ -3300,7 +3304,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-02-11T13:38:19+00:00" + "time": "2025-07-23T14:16:08+00:00" }, { "name": "laravel/tinker", @@ -3506,16 +3510,16 @@ }, { "name": "league/commonmark", - "version": "2.6.1", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "d990688c91cedfb69753ffc2512727ec646df2ad" + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad", - "reference": "d990688c91cedfb69753ffc2512727ec646df2ad", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", "shasum": "" }, "require": { @@ -3544,7 +3548,7 @@ "symfony/process": "^5.4 | ^6.0 | ^7.0", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -3552,7 +3556,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.7-dev" + "dev-main": "2.8-dev" } }, "autoload": { @@ -3609,7 +3613,7 @@ "type": "tidelift" } ], - "time": "2024-12-29T14:10:59+00:00" + "time": "2025-07-20T12:47:49+00:00" }, { "name": "league/config", @@ -3695,16 +3699,16 @@ }, { "name": "league/flysystem", - "version": "3.29.1", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319" + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", "shasum": "" }, "require": { @@ -3728,13 +3732,13 @@ "composer/semver": "^3.0", "ext-fileinfo": "*", "ext-ftp": "*", - "ext-mongodb": "^1.3", + "ext-mongodb": "^1.3|^2", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.5", "google/cloud-storage": "^1.23", "guzzlehttp/psr7": "^2.6", "microsoft/azure-storage-blob": "^1.1", - "mongodb/mongodb": "^1.2", + "mongodb/mongodb": "^1.2|^2", "phpseclib/phpseclib": "^3.0.36", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5.11|^10.0", @@ -3772,9 +3776,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.29.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" }, - "time": "2024-10-08T08:58:34+00:00" + "time": "2025-06-25T13:29:59+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -3833,16 +3837,16 @@ }, { "name": "league/flysystem-local", - "version": "3.29.0", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", "shasum": "" }, "require": { @@ -3876,22 +3880,22 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" }, - "time": "2024-08-09T21:24:39+00:00" + "time": "2025-05-21T10:34:19+00:00" }, { "name": "league/flysystem-sftp-v3", - "version": "3.29.0", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-sftp-v3.git", - "reference": "ce9b209e2fbe33122c755ffc18eb4d5bd256f252" + "reference": "93f297837b5052f4cfee601b2e0352addd956448" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/ce9b209e2fbe33122c755ffc18eb4d5bd256f252", - "reference": "ce9b209e2fbe33122c755ffc18eb4d5bd256f252", + "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/93f297837b5052f4cfee601b2e0352addd956448", + "reference": "93f297837b5052f4cfee601b2e0352addd956448", "shasum": "" }, "require": { @@ -3925,9 +3929,9 @@ "sftp" ], "support": { - "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.30.0" }, - "time": "2024-08-14T19:35:54+00:00" + "time": "2025-04-17T15:49:35+00:00" }, { "name": "league/mime-type-detection", @@ -4237,16 +4241,16 @@ }, { "name": "livewire/livewire", - "version": "v3.6.2", + "version": "v3.6.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "8f8914731f5eb43b6bb145d87c8d5a9edfc89313" + "reference": "ef04be759da41b14d2d129e670533180a44987dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/8f8914731f5eb43b6bb145d87c8d5a9edfc89313", - "reference": "8f8914731f5eb43b6bb145d87c8d5a9edfc89313", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc", + "reference": "ef04be759da41b14d2d129e670533180a44987dc", "shasum": "" }, "require": { @@ -4301,7 +4305,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.6.2" + "source": "https://github.com/livewire/livewire/tree/v3.6.4" }, "funding": [ { @@ -4309,7 +4313,7 @@ "type": "github" } ], - "time": "2025-03-12T20:24:15+00:00" + "time": "2025-07-17T05:12:15+00:00" }, { "name": "log1x/laravel-webfonts", @@ -4692,16 +4696,16 @@ }, { "name": "nesbot/carbon", - "version": "3.8.6", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd" + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd", - "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", "shasum": "" }, "require": { @@ -4709,9 +4713,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -4719,14 +4723,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.57.2", + "friendsofphp/php-cs-fixer": "^3.75.0", "kylekatarnls/multi-tester": "^2.5.3", - "ondrejmirtes/better-reflection": "^6.25.0.4", "phpmd/phpmd": "^2.15.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.11.2", - "phpunit/phpunit": "^10.5.20", - "squizlabs/php_codesniffer": "^3.9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" }, "bin": [ "bin/carbon" @@ -4794,7 +4797,7 @@ "type": "tidelift" } ], - "time": "2025-02-20T17:33:38+00:00" + "time": "2025-08-02T09:36:06+00:00" }, { "name": "nette/schema", @@ -4860,16 +4863,16 @@ }, { "name": "nette/utils", - "version": "v4.0.6", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "ce708655043c7050eb050df361c5e313cf708309" + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/ce708655043c7050eb050df361c5e313cf708309", - "reference": "ce708655043c7050eb050df361c5e313cf708309", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", "shasum": "" }, "require": { @@ -4940,22 +4943,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.6" + "source": "https://github.com/nette/utils/tree/v4.0.7" }, - "time": "2025-03-30T21:06:30+00:00" + "time": "2025-06-03T04:55:08+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", "shasum": "" }, "require": { @@ -4998,9 +5001,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-07-27T20:03:57+00:00" }, { "name": "nubs/random-name-generator", @@ -5057,31 +5060,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.1.8" + "symfony/console": "^7.2.6" }, "require-dev": { - "illuminate/console": "^11.33.2", - "laravel/pint": "^1.18.2", + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^7.1.8", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -5124,7 +5127,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" }, "funding": [ { @@ -5140,7 +5143,7 @@ "type": "github" } ], - "time": "2024-11-21T10:39:51+00:00" + "time": "2025-05-08T08:14:37+00:00" }, { "name": "nyholm/psr7", @@ -5485,16 +5488,16 @@ }, { "name": "php-di/php-di", - "version": "7.0.9", + "version": "7.0.11", "source": { "type": "git", "url": "https://github.com/PHP-DI/PHP-DI.git", - "reference": "d8480267f5cf239650debba704f3ecd15b638cde" + "reference": "32f111a6d214564520a57831d397263e8946c1d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/d8480267f5cf239650debba704f3ecd15b638cde", - "reference": "d8480267f5cf239650debba704f3ecd15b638cde", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/32f111a6d214564520a57831d397263e8946c1d2", + "reference": "32f111a6d214564520a57831d397263e8946c1d2", "shasum": "" }, "require": { @@ -5510,7 +5513,7 @@ "friendsofphp/php-cs-fixer": "^3", "friendsofphp/proxy-manager-lts": "^1", "mnapoli/phpunit-easymock": "^1.3", - "phpunit/phpunit": "^9.6", + "phpunit/phpunit": "^9.6 || ^10 || ^11", "vimeo/psalm": "^5|^6" }, "suggest": { @@ -5542,7 +5545,7 @@ ], "support": { "issues": "https://github.com/PHP-DI/PHP-DI/issues", - "source": "https://github.com/PHP-DI/PHP-DI/tree/7.0.9" + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.0.11" }, "funding": [ { @@ -5554,23 +5557,24 @@ "type": "tidelift" } ], - "time": "2025-02-28T12:46:35+00:00" + "time": "2025-06-03T07:45:57+00:00" }, { "name": "phpdocumentor/reflection", - "version": "6.1.0", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/Reflection.git", - "reference": "bb4dea805a645553d6d989b23dad9f8041f39502" + "reference": "d91b3270832785602adcc24ae2d0974ba99a8ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/bb4dea805a645553d6d989b23dad9f8041f39502", - "reference": "bb4dea805a645553d6d989b23dad9f8041f39502", + "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/d91b3270832785602adcc24ae2d0974ba99a8ff8", + "reference": "d91b3270832785602adcc24ae2d0974ba99a8ff8", "shasum": "" }, "require": { + "composer-runtime-api": "^2", "nikic/php-parser": "~4.18 || ^5.0", "php": "8.1.*|8.2.*|8.3.*|8.4.*", "phpdocumentor/reflection-common": "^2.1", @@ -5581,7 +5585,8 @@ }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/coding-standard": "^12.0", + "doctrine/coding-standard": "^13.0", + "eliashaeussler/phpunit-attributes": "^1.7", "mikey179/vfsstream": "~1.2", "mockery/mockery": "~1.6.0", "phpspec/prophecy-phpunit": "^2.0", @@ -5589,7 +5594,7 @@ "phpstan/phpstan": "^1.8", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^10.0", - "psalm/phar": "^5.24", + "psalm/phar": "^6.0", "rector/rector": "^1.0.0", "squizlabs/php_codesniffer": "^3.8" }, @@ -5601,6 +5606,9 @@ } }, "autoload": { + "files": [ + "src/php-parser/Modifiers.php" + ], "psr-4": { "phpDocumentor\\": "src/phpDocumentor" } @@ -5619,9 +5627,9 @@ ], "support": { "issues": "https://github.com/phpDocumentor/Reflection/issues", - "source": "https://github.com/phpDocumentor/Reflection/tree/6.1.0" + "source": "https://github.com/phpDocumentor/Reflection/tree/6.3.0" }, - "time": "2024-11-22T15:11:54+00:00" + "time": "2025-06-06T13:39:18+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -5678,16 +5686,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "version": "5.6.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", "shasum": "" }, "require": { @@ -5736,9 +5744,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" }, - "time": "2024-12-07T09:39:29+00:00" + "time": "2025-04-13T19:20:35+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -5875,16 +5883,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.43", + "version": "3.0.46", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "709ec107af3cb2f385b9617be72af8cf62441d02" + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02", - "reference": "709ec107af3cb2f385b9617be72af8cf62441d02", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", "shasum": "" }, "require": { @@ -5965,7 +5973,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" }, "funding": [ { @@ -5981,20 +5989,20 @@ "type": "tidelift" } ], - "time": "2024-12-14T21:12:59+00:00" + "time": "2025-06-26T16:29:55+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", "shasum": "" }, "require": { @@ -6026,9 +6034,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-07-13T07:04:09+00:00" }, { "name": "pion/laravel-chunk-upload", @@ -6655,16 +6663,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.8", + "version": "v0.12.10", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625" + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85057ceedee50c49d4f6ecaff73ee96adb3b3625", - "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", "shasum": "" }, "require": { @@ -6714,12 +6722,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -6728,9 +6735,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.8" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" }, - "time": "2025-03-16T03:05:19+00:00" + "time": "2025-08-04T12:39:37+00:00" }, { "name": "purplepixie/phpdns", @@ -6963,21 +6970,20 @@ }, { "name": "ramsey/uuid", - "version": "4.7.6", + "version": "4.9.0", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088" + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", - "ext-json": "*", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -6985,26 +6991,23 @@ "rhumsaa/uuid": "self.version" }, "require-dev": { - "captainhook/captainhook": "^5.10", + "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "doctrine/annotations": "^1.8", - "ergebnis/composer-normalize": "^2.15", - "mockery/mockery": "^1.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.2", - "php-mock/php-mock-mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.1", - "phpbench/phpbench": "^1.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^8.5 || ^9", - "ramsey/composer-repl": "^1.4", - "slevomat/coding-standard": "^8.4", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.9" + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" }, "suggest": { "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", @@ -7039,39 +7042,29 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.6" + "source": "https://github.com/ramsey/uuid/tree/4.9.0" }, - "funding": [ - { - "url": "https://github.com/ramsey", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" - } - ], - "time": "2024-04-27T21:32:50+00:00" + "time": "2025-06-25T14:20:11+00:00" }, { "name": "resend/resend-laravel", - "version": "v0.17.0", + "version": "v0.19.0", "source": { "type": "git", "url": "https://github.com/resend/resend-laravel.git", - "reference": "fbdf51872ff296af545d72acc6de03c0d910202c" + "reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/resend/resend-laravel/zipball/fbdf51872ff296af545d72acc6de03c0d910202c", - "reference": "fbdf51872ff296af545d72acc6de03c0d910202c", + "url": "https://api.github.com/repos/resend/resend-laravel/zipball/ce11e363c42c1d6b93983dfebbaba3f906863c3a", + "reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a", "shasum": "" }, "require": { "illuminate/http": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", "php": "^8.1", - "resend/resend-php": "^0.16.0", + "resend/resend-php": "^0.18.0", "symfony/mailer": "^6.2|^7.0" }, "require-dev": { @@ -7118,22 +7111,22 @@ ], "support": { "issues": "https://github.com/resend/resend-laravel/issues", - "source": "https://github.com/resend/resend-laravel/tree/v0.17.0" + "source": "https://github.com/resend/resend-laravel/tree/v0.19.0" }, - "time": "2025-03-25T00:42:52+00:00" + "time": "2025-05-06T21:36:51+00:00" }, { "name": "resend/resend-php", - "version": "v0.16.0", + "version": "v0.18.1", "source": { "type": "git", "url": "https://github.com/resend/resend-php.git", - "reference": "6e9be898c9e0035a5da3c2904e86d0b12999c2fc" + "reference": "f20a9a50a7f705294af1662995c93bfd806fed3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/resend/resend-php/zipball/6e9be898c9e0035a5da3c2904e86d0b12999c2fc", - "reference": "6e9be898c9e0035a5da3c2904e86d0b12999c2fc", + "url": "https://api.github.com/repos/resend/resend-php/zipball/f20a9a50a7f705294af1662995c93bfd806fed3e", + "reference": "f20a9a50a7f705294af1662995c93bfd806fed3e", "shasum": "" }, "require": { @@ -7175,9 +7168,9 @@ ], "support": { "issues": "https://github.com/resend/resend-php/issues", - "source": "https://github.com/resend/resend-php/tree/v0.16.0" + "source": "https://github.com/resend/resend-php/tree/v0.18.1" }, - "time": "2025-03-24T22:12:48+00:00" + "time": "2025-07-04T00:12:53+00:00" }, { "name": "revolt/event-loop", @@ -7253,16 +7246,16 @@ }, { "name": "sentry/sentry", - "version": "4.10.0", + "version": "4.14.2", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "2af937d47d8aadb8dab0b1d7b9557e495dd12856" + "reference": "bfeec74303d60d3f8bc33701ab3e86f8a8729f17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/2af937d47d8aadb8dab0b1d7b9557e495dd12856", - "reference": "2af937d47d8aadb8dab0b1d7b9557e495dd12856", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/bfeec74303d60d3f8bc33701ab3e86f8a8729f17", + "reference": "bfeec74303d60d3f8bc33701ab3e86f8a8729f17", "shasum": "" }, "require": { @@ -7326,7 +7319,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.10.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.14.2" }, "funding": [ { @@ -7338,27 +7331,27 @@ "type": "custom" } ], - "time": "2024-11-06T07:44:19+00:00" + "time": "2025-07-21T08:28:29+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.13.0", + "version": "4.15.1", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "d232ac494258e0d50a77c575a5af5f1a426d3f87" + "reference": "7e0675e8e06d1ec5cb623792892920000a3aedb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/d232ac494258e0d50a77c575a5af5f1a426d3f87", - "reference": "d232ac494258e0d50a77c575a5af5f1a426d3f87", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/7e0675e8e06d1ec5cb623792892920000a3aedb5", + "reference": "7e0675e8e06d1ec5cb623792892920000a3aedb5", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.10", + "sentry/sentry": "^4.14.1", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0" }, "require-dev": { @@ -7415,7 +7408,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.13.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.15.1" }, "funding": [ { @@ -7427,7 +7420,7 @@ "type": "custom" } ], - "time": "2025-02-18T10:09:29+00:00" + "time": "2025-06-24T12:39:03+00:00" }, { "name": "socialiteproviders/authentik", @@ -7479,6 +7472,106 @@ }, "time": "2023-11-07T22:21:16+00:00" }, + { + "name": "socialiteproviders/clerk", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Clerk.git", + "reference": "41e123036001ff37851b9622a910010c0e487d6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Clerk/zipball/41e123036001ff37851b9622a910010c0e487d6a", + "reference": "41e123036001ff37851b9622a910010c0e487d6a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "socialiteproviders/manager": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Clerk\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignacio Cano", + "email": "dev@nacho.sh" + } + ], + "description": "Clerk OAuth2 Provider for Laravel Socialite", + "keywords": [ + "clerk", + "laravel", + "oauth", + "provider", + "socialite" + ], + "support": { + "docs": "https://socialiteproviders.com/clerk", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2024-02-19T12:17:59+00:00" + }, + { + "name": "socialiteproviders/discord", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Discord.git", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Discord\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Eklund", + "email": "eklundchristopher@gmail.com" + } + ], + "description": "Discord OAuth2 Provider for Laravel Socialite", + "keywords": [ + "discord", + "laravel", + "oauth", + "provider", + "socialite" + ], + "support": { + "docs": "https://socialiteproviders.com/discord", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2023-07-24T23:28:47+00:00" + }, { "name": "socialiteproviders/google", "version": "4.1.0", @@ -7697,17 +7790,67 @@ "time": "2024-03-15T03:02:10+00:00" }, { - "name": "spatie/backtrace", - "version": "1.7.1", + "name": "socialiteproviders/zitadel", + "version": "4.2.0", "source": { "type": "git", - "url": "https://github.com/spatie/backtrace.git", - "reference": "0f2477c520e3729de58e061b8192f161c99f770b" + "url": "https://github.com/SocialiteProviders/Zitadel.git", + "reference": "852ceebd71503ac116c42b8aa352e22b4fd396d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/backtrace/zipball/0f2477c520e3729de58e061b8192f161c99f770b", - "reference": "0f2477c520e3729de58e061b8192f161c99f770b", + "url": "https://api.github.com/repos/SocialiteProviders/Zitadel/zipball/852ceebd71503ac116c42b8aa352e22b4fd396d9", + "reference": "852ceebd71503ac116c42b8aa352e22b4fd396d9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "socialiteproviders/manager": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Zitadel\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gurkirat Singh", + "email": "tbhaxor@gmail.com" + } + ], + "description": "Zitadel OAuth2 Provider for Laravel Socialite", + "keywords": [ + "laravel", + "oauth", + "provider", + "socialite", + "zitadel" + ], + "support": { + "docs": "https://socialiteproviders.com/zitadel", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2024-11-07T21:57:40+00:00" + }, + { + "name": "spatie/backtrace", + "version": "1.7.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/backtrace.git", + "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/cd37a49fce7137359ac30ecc44ef3e16404cccbe", + "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe", "shasum": "" }, "require": { @@ -7745,7 +7888,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/backtrace/tree/1.7.1" + "source": "https://github.com/spatie/backtrace/tree/1.7.4" }, "funding": [ { @@ -7757,20 +7900,80 @@ "type": "other" } ], - "time": "2024-12-02T13:28:15+00:00" + "time": "2025-05-08T15:41:09+00:00" }, { - "name": "spatie/laravel-activitylog", - "version": "4.10.1", + "name": "spatie/commonmark-shiki-highlighter", + "version": "2.5.1", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-activitylog.git", - "reference": "466f30f7245fe3a6e328ad5e6812bd43b4bddea5" + "url": "https://github.com/spatie/commonmark-shiki-highlighter.git", + "reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/466f30f7245fe3a6e328ad5e6812bd43b4bddea5", - "reference": "466f30f7245fe3a6e328ad5e6812bd43b4bddea5", + "url": "https://api.github.com/repos/spatie/commonmark-shiki-highlighter/zipball/595c7e0b45d4a63b17dfc1ccbd13532d431ec351", + "reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.4.2", + "php": "^8.0", + "spatie/shiki-php": "^2.2.2", + "symfony/process": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.19|^v3.49.0", + "phpunit/phpunit": "^9.5", + "spatie/phpunit-snapshot-assertions": "^4.2.7", + "spatie/ray": "^1.28" + }, + "type": "commonmark-extension", + "autoload": { + "psr-4": { + "Spatie\\CommonMarkShikiHighlighter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Highlight code blocks with league/commonmark and Shiki", + "homepage": "https://github.com/spatie/commonmark-shiki-highlighter", + "keywords": [ + "commonmark-shiki-highlighter", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/commonmark-shiki-highlighter/tree/2.5.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-01-13T11:25:47+00:00" + }, + { + "name": "spatie/laravel-activitylog", + "version": "4.10.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "bb879775d487438ed9a99e64f09086b608990c10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bb879775d487438ed9a99e64f09086b608990c10", + "reference": "bb879775d487438ed9a99e64f09086b608990c10", "shasum": "" }, "require": { @@ -7836,7 +8039,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-activitylog/issues", - "source": "https://github.com/spatie/laravel-activitylog/tree/4.10.1" + "source": "https://github.com/spatie/laravel-activitylog/tree/4.10.2" }, "funding": [ { @@ -7848,20 +8051,20 @@ "type": "github" } ], - "time": "2025-02-10T15:38:25+00:00" + "time": "2025-06-15T06:59:49+00:00" }, { "name": "spatie/laravel-data", - "version": "4.14.1", + "version": "4.17.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "edd61b4dca5acdcfd1e3b7f2c19b75e83730f87c" + "reference": "6b110d25ad4219774241b083d09695b20a7fb472" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/edd61b4dca5acdcfd1e3b7f2c19b75e83730f87c", - "reference": "edd61b4dca5acdcfd1e3b7f2c19b75e83730f87c", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/6b110d25ad4219774241b083d09695b20a7fb472", + "reference": "6b110d25ad4219774241b083d09695b20a7fb472", "shasum": "" }, "require": { @@ -7874,6 +8077,7 @@ "require-dev": { "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", + "inertiajs/inertia-laravel": "^2.0", "livewire/livewire": "^3.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63|^3.0", @@ -7922,7 +8126,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.14.1" + "source": "https://github.com/spatie/laravel-data/tree/4.17.0" }, "funding": [ { @@ -7930,20 +8134,96 @@ "type": "github" } ], - "time": "2025-03-17T13:54:28+00:00" + "time": "2025-06-25T11:36:37+00:00" }, { - "name": "spatie/laravel-package-tools", - "version": "1.92.0", + "name": "spatie/laravel-markdown", + "version": "2.7.1", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "dd46cd0ed74015db28822d88ad2e667f4496a6f6" + "url": "https://github.com/spatie/laravel-markdown.git", + "reference": "353e7f9fae62826e26cbadef58a12ecf39685280" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/dd46cd0ed74015db28822d88ad2e667f4496a6f6", - "reference": "dd46cd0ed74015db28822d88ad2e667f4496a6f6", + "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280", + "reference": "353e7f9fae62826e26cbadef58a12ecf39685280", + "shasum": "" + }, + "require": { + "illuminate/cache": "^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "illuminate/view": "^9.0|^10.0|^11.0|^12.0", + "league/commonmark": "^2.6.0", + "php": "^8.1", + "spatie/commonmark-shiki-highlighter": "^2.5", + "spatie/laravel-package-tools": "^1.4.3" + }, + "require-dev": { + "brianium/paratest": "^6.2|^7.8", + "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0", + "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0", + "pestphp/pest": "^1.22|^2.0|^3.7", + "phpunit/phpunit": "^9.3|^11.5.3", + "spatie/laravel-ray": "^1.23", + "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0", + "vimeo/psalm": "^4.8|^6.7" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\LaravelMarkdown\\MarkdownServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelMarkdown\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "A highly configurable markdown renderer and Blade component for Laravel", + "homepage": "https://github.com/spatie/laravel-markdown", + "keywords": [ + "Laravel-Markdown", + "laravel", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-21T13:43:18+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.92.7", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", "shasum": "" }, "require": { @@ -7954,6 +8234,7 @@ "mockery/mockery": "^1.5", "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", "phpunit/phpunit": "^9.5.24|^10.5|^11.5", "spatie/pest-plugin-test-time": "^1.1|^2.2" }, @@ -7982,7 +8263,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.0" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" }, "funding": [ { @@ -7990,7 +8271,7 @@ "type": "github" } ], - "time": "2025-03-27T08:34:10+00:00" + "time": "2025-07-17T15:46:43+00:00" }, { "name": "spatie/laravel-ray", @@ -8287,16 +8568,16 @@ }, { "name": "spatie/ray", - "version": "1.41.6", + "version": "1.42.0", "source": { "type": "git", "url": "https://github.com/spatie/ray.git", - "reference": "ae6e32a54a901544a3d70b12b865900bc240f71c" + "reference": "152250ce7c490bf830349fa30ba5200084e95860" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/ae6e32a54a901544a3d70b12b865900bc240f71c", - "reference": "ae6e32a54a901544a3d70b12b865900bc240f71c", + "url": "https://api.github.com/repos/spatie/ray/zipball/152250ce7c490bf830349fa30ba5200084e95860", + "reference": "152250ce7c490bf830349fa30ba5200084e95860", "shasum": "" }, "require": { @@ -8356,7 +8637,7 @@ ], "support": { "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.41.6" + "source": "https://github.com/spatie/ray/tree/1.42.0" }, "funding": [ { @@ -8368,7 +8649,72 @@ "type": "other" } ], - "time": "2025-03-21T08:56:30+00:00" + "time": "2025-04-18T08:17:40+00:00" + }, + { + "name": "spatie/shiki-php", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/shiki-php.git", + "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "symfony/process": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.0", + "pestphp/pest": "^1.8", + "phpunit/phpunit": "^9.5", + "spatie/pest-plugin-snapshots": "^1.1", + "spatie/ray": "^1.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ShikiPhp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rias Van der Veken", + "email": "rias@spatie.be", + "role": "Developer" + }, + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Highlight code using Shiki in PHP", + "homepage": "https://github.com/spatie/shiki-php", + "keywords": [ + "shiki", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/shiki-php/tree/2.3.2" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-21T14:16:57+00:00" }, { "name": "spatie/url", @@ -8434,16 +8780,16 @@ }, { "name": "stevebauman/purify", - "version": "v6.3.0", + "version": "v6.3.1", "source": { "type": "git", "url": "https://github.com/stevebauman/purify.git", - "reference": "2e5e6e1bfe072189b6056c6ad4a8c68ba57f3ba1" + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/stevebauman/purify/zipball/2e5e6e1bfe072189b6056c6ad4a8c68ba57f3ba1", - "reference": "2e5e6e1bfe072189b6056c6ad4a8c68ba57f3ba1", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", "shasum": "" }, "require": { @@ -8494,9 +8840,9 @@ ], "support": { "issues": "https://github.com/stevebauman/purify/issues", - "source": "https://github.com/stevebauman/purify/tree/v6.3.0" + "source": "https://github.com/stevebauman/purify/tree/v6.3.1" }, - "time": "2025-02-18T23:08:15+00:00" + "time": "2025-05-21T16:53:09+00:00" }, { "name": "stripe/stripe-php", @@ -8559,7 +8905,7 @@ }, { "name": "symfony/clock", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", @@ -8613,7 +8959,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.2.0" + "source": "https://github.com/symfony/clock/tree/v7.3.0" }, "funding": [ { @@ -8633,23 +8979,24 @@ }, { "name": "symfony/console", - "version": "v7.2.5", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88" + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -8706,7 +9053,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.5" + "source": "https://github.com/symfony/console/tree/v7.3.2" }, "funding": [ { @@ -8717,16 +9064,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-12T08:11:12+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/css-selector", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", @@ -8771,7 +9122,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.2.0" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" }, "funding": [ { @@ -8791,16 +9142,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -8813,7 +9164,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -8838,7 +9189,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -8854,20 +9205,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/error-handler", - "version": "v7.2.5", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b" + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", "shasum": "" }, "require": { @@ -8880,9 +9231,11 @@ "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0" + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -8913,7 +9266,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.2.5" + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" }, "funding": [ { @@ -8924,25 +9277,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-07-07T08:17:57+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", "shasum": "" }, "require": { @@ -8993,7 +9350,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" }, "funding": [ { @@ -9009,20 +9366,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-04-22T09:11:45+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -9036,7 +9393,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -9069,7 +9426,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -9085,20 +9442,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/finder", - "version": "v7.2.2", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb" + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", "shasum": "" }, "require": { @@ -9133,7 +9490,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.2" + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -9144,25 +9501,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:17+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.2.5", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "371272aeb6286f8135e028ca535f8e4d6f114126" + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/371272aeb6286f8135e028ca535f8e4d6f114126", - "reference": "371272aeb6286f8135e028ca535f8e4d6f114126", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", "shasum": "" }, "require": { @@ -9179,6 +9540,7 @@ "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", @@ -9211,7 +9573,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.2.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" }, "funding": [ { @@ -9222,25 +9584,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-25T15:54:33+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.2.5", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54" + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b1fe91bc1fa454a806d3f98db4ba826eb9941a54", - "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", "shasum": "" }, "require": { @@ -9248,8 +9614,8 @@ "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -9325,7 +9691,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.2.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" }, "funding": [ { @@ -9336,25 +9702,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-28T13:32:50+00:00" + "time": "2025-07-31T10:45:04+00:00" }, { "name": "symfony/mailer", - "version": "v7.2.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3" + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f3871b182c44997cf039f3b462af4a48fb85f9d3", - "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", "shasum": "" }, "require": { @@ -9405,7 +9775,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.3" + "source": "https://github.com/symfony/mailer/tree/v7.3.2" }, "funding": [ { @@ -9416,25 +9786,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-27T11:08:17+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/mime", - "version": "v7.2.4", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "87ca22046b78c3feaff04b337f33b38510fd686b" + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/87ca22046b78c3feaff04b337f33b38510fd686b", - "reference": "87ca22046b78c3feaff04b337f33b38510fd686b", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", "shasum": "" }, "require": { @@ -9489,7 +9863,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.2.4" + "source": "https://github.com/symfony/mime/tree/v7.3.2" }, "funding": [ { @@ -9500,25 +9874,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:20+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.2.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50" + "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/7da8fbac9dcfef75ffc212235d76b2754ce0cf50", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/119bcf13e67dbd188e5dbc74228b1686f66acd37", + "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37", "shasum": "" }, "require": { @@ -9556,7 +9934,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.2.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.2" }, "funding": [ { @@ -9567,16 +9945,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-20T11:17:29+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -9635,7 +10017,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -9655,16 +10037,16 @@ }, { "name": "symfony/polyfill-iconv", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956" + "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/48becf00c920479ca2e910c22a5a39e5d47ca956", - "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa", + "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa", "shasum": "" }, "require": { @@ -9715,7 +10097,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.32.0" }, "funding": [ { @@ -9731,11 +10113,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-17T14:58:18+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -9793,7 +10175,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -9813,16 +10195,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { @@ -9876,7 +10258,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" }, "funding": [ { @@ -9892,11 +10274,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -9957,7 +10339,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -9977,19 +10359,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -10037,7 +10420,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -10053,20 +10436,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -10117,7 +10500,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -10133,11 +10516,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -10193,7 +10576,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -10213,7 +10596,7 @@ }, { "name": "symfony/polyfill-uuid", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -10272,7 +10655,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" }, "funding": [ { @@ -10292,16 +10675,16 @@ }, { "name": "symfony/process", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { @@ -10333,7 +10716,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.5" + "source": "https://github.com/symfony/process/tree/v7.3.0" }, "funding": [ { @@ -10349,11 +10732,11 @@ "type": "tidelift" } ], - "time": "2025-03-13T12:21:46+00:00" + "time": "2025-04-17T09:11:12+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", @@ -10416,7 +10799,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.2.0" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" }, "funding": [ { @@ -10436,16 +10819,16 @@ }, { "name": "symfony/routing", - "version": "v7.2.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996" + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ee9a67edc6baa33e5fae662f94f91fd262930996", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", "shasum": "" }, "require": { @@ -10497,7 +10880,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.2.3" + "source": "https://github.com/symfony/routing/tree/v7.3.2" }, "funding": [ { @@ -10508,25 +10891,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-17T10:56:55+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -10544,7 +10931,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -10580,7 +10967,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -10596,11 +10983,11 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.2.4", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", @@ -10642,7 +11029,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.2.4" + "source": "https://github.com/symfony/stopwatch/tree/v7.3.0" }, "funding": [ { @@ -10662,16 +11049,16 @@ }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { @@ -10729,7 +11116,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -10740,25 +11127,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/translation", - "version": "v7.2.4", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a" + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a", - "reference": "283856e6981286cc0d800b53bd5703e8e363f05a", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", "shasum": "" }, "require": { @@ -10768,6 +11159,7 @@ "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { + "nikic/php-parser": "<5.0", "symfony/config": "<6.4", "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", @@ -10781,7 +11173,7 @@ "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", "symfony/config": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", @@ -10824,7 +11216,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.2.4" + "source": "https://github.com/symfony/translation/tree/v7.3.2" }, "funding": [ { @@ -10835,25 +11227,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-13T10:27:23+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", "shasum": "" }, "require": { @@ -10866,7 +11262,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -10902,7 +11298,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" }, "funding": [ { @@ -10918,20 +11314,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-27T08:32:26+00:00" }, { "name": "symfony/uid", - "version": "v7.2.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426" + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", "shasum": "" }, "require": { @@ -10976,7 +11372,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.2.0" + "source": "https://github.com/symfony/uid/tree/v7.3.1" }, "funding": [ { @@ -10992,31 +11388,31 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.2.3", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a" + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -11059,7 +11455,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, "funding": [ { @@ -11070,25 +11466,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-17T11:39:41+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/yaml", - "version": "v7.2.5", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912" + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912", - "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", "shasum": "" }, "require": { @@ -11131,7 +11531,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.5" + "source": "https://github.com/symfony/yaml/tree/v7.3.2" }, "funding": [ { @@ -11142,12 +11542,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11264,16 +11668,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { @@ -11332,7 +11736,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -11344,7 +11748,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:52:34+00:00" + "time": "2025-04-30T23:37:27+00:00" }, { "name": "voku/portable-ascii", @@ -11798,16 +12202,16 @@ }, { "name": "zircote/swagger-php", - "version": "5.0.7", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "18457fa71f753cfd4a2b21916baf329864fdfaa6" + "reference": "471f2e7c24c9508a2ee08df245cab64b62dbf721" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/18457fa71f753cfd4a2b21916baf329864fdfaa6", - "reference": "18457fa71f753cfd4a2b21916baf329864fdfaa6", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/471f2e7c24c9508a2ee08df245cab64b62dbf721", + "reference": "471f2e7c24c9508a2ee08df245cab64b62dbf721", "shasum": "" }, "require": { @@ -11868,8 +12272,8 @@ "homepage": "https://radebatz.net" } ], - "description": "swagger-php - Generate interactive documentation for your RESTful API using phpdoc annotations", - "homepage": "https://github.com/zircote/swagger-php/", + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", "keywords": [ "api", "json", @@ -11878,24 +12282,24 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.0.7" + "source": "https://github.com/zircote/swagger-php/tree/5.1.4" }, - "time": "2025-03-19T03:31:11+00:00" + "time": "2025-07-15T23:54:13+00:00" } ], "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v3.15.2", + "version": "v3.16.0", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "0bc1e1361e7fffc2be156f46ad1fba6927c01729" + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/0bc1e1361e7fffc2be156f46ad1fba6927c01729", - "reference": "0bc1e1361e7fffc2be156f46ad1fba6927c01729", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/f265cf5e38577d42311f1a90d619bcd3740bea23", + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23", "shasum": "" }, "require": { @@ -11903,12 +12307,9 @@ "illuminate/session": "^9|^10|^11|^12", "illuminate/support": "^9|^10|^11|^12", "php": "^8.1", - "php-debugbar/php-debugbar": "~2.1.1", + "php-debugbar/php-debugbar": "~2.2.0", "symfony/finder": "^6|^7" }, - "conflict": { - "maximebf/debugbar": "*" - }, "require-dev": { "mockery/mockery": "^1.3.3", "orchestra/testbench-dusk": "^7|^8|^9|^10", @@ -11926,7 +12327,7 @@ ] }, "branch-alias": { - "dev-master": "3.15-dev" + "dev-master": "3.16-dev" } }, "autoload": { @@ -11958,7 +12359,7 @@ ], "support": { "issues": "https://github.com/barryvdh/laravel-debugbar/issues", - "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.15.2" + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.0" }, "funding": [ { @@ -11970,7 +12371,7 @@ "type": "github" } ], - "time": "2025-02-25T15:25:22+00:00" + "time": "2025-07-14T11:56:43+00:00" }, { "name": "brianium/paratest", @@ -12067,20 +12468,20 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.0.2", + "version": "2.0.5", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "f0e3a9e2c92ff760730d1af34fbdc43f51f3b868" + "reference": "ac61de4f267c23249d175d7fc9149fd01528567d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/f0e3a9e2c92ff760730d1af34fbdc43f51f3b868", - "reference": "f0e3a9e2c92ff760730d1af34fbdc43f51f3b868", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/ac61de4f267c23249d175d7fc9149fd01528567d", + "reference": "ac61de4f267c23249d175d7fc9149fd01528567d", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", + "php": "^7.4 || ^8.0", "rector/rector": "^2.0" }, "type": "rector-extension", @@ -12096,9 +12497,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.0.2" + "source": "https://github.com/driftingly/rector-laravel/tree/2.0.5" }, - "time": "2025-01-17T18:07:03+00:00" + "time": "2025-05-14T17:30:41+00:00" }, { "name": "fakerphp/faker", @@ -12226,16 +12627,16 @@ }, { "name": "filp/whoops", - "version": "2.18.0", + "version": "2.18.3", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e" + "reference": "59a123a3d459c5a23055802237cb317f609867e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e", - "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", "shasum": "" }, "require": { @@ -12285,7 +12686,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.0" + "source": "https://github.com/filp/whoops/tree/2.18.3" }, "funding": [ { @@ -12293,24 +12694,24 @@ "type": "github" } ], - "time": "2025-03-15T12:00:00+00:00" + "time": "2025-06-16T00:02:10+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -12318,8 +12719,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -12342,22 +12743,87 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { - "name": "laravel/dusk", - "version": "v8.3.2", + "name": "laravel/boost", + "version": "v1.1.4", "source": { "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "bb701836357bf6f6c6658ef90b5a0f8232affb0f" + "url": "https://github.com/laravel/boost.git", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/bb701836357bf6f6c6658ef90b5a0f8232affb0f", - "reference": "bb701836357bf6f6c6658ef90b5a0f8232affb0f", + "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.1", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2.5", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-09-04T12:16:09+00:00" + }, + { + "name": "laravel/dusk", + "version": "v8.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "077d448cd993a08f97bfccf0ea3d6478b3908f7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/077d448cd993a08f97bfccf0ea3d6478b3908f7e", + "reference": "077d448cd993a08f97bfccf0ea3d6478b3908f7e", "shasum": "" }, "require": { @@ -12416,22 +12882,86 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.3.2" + "source": "https://github.com/laravel/dusk/tree/v8.3.3" }, - "time": "2025-02-20T14:42:00+00:00" + "time": "2025-06-10T13:59:27+00:00" }, { - "name": "laravel/pint", - "version": "v1.21.2", + "name": "laravel/mcp", + "version": "v0.1.1", "source": { "type": "git", - "url": "https://github.com/laravel/pint.git", - "reference": "370772e7d9e9da087678a0edf2b11b6960e40558" + "url": "https://github.com/laravel/mcp.git", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/370772e7d9e9da087678a0edf2b11b6960e40558", - "reference": "370772e7d9e9da087678a0edf2b11b6960e40558", + "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-16T09:50:43+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -12442,12 +12972,12 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.72.0", - "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.2.0", - "laravel-zero/framework": "^11.36.1", + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -12455,6 +12985,9 @@ ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -12484,20 +13017,81 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-03-14T22:31:42+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { - "name": "laravel/telescope", - "version": "v5.6.0", + "name": "laravel/roster", + "version": "v0.2.6", "source": { "type": "git", - "url": "https://github.com/laravel/telescope.git", - "reference": "352dc633c2fe500c169e3401a10a7efae4fc3327" + "url": "https://github.com/laravel/roster.git", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/352dc633c2fe500c169e3401a10a7efae4fc3327", - "reference": "352dc633c2fe500c169e3401a10a7efae4fc3327", + "url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-09-04T07:31:39+00:00" + }, + { + "name": "laravel/telescope", + "version": "v5.10.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/telescope.git", + "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/telescope/zipball/6d249d93ab06dc147ac62ea02b4272c2e7a24b72", + "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72", "shasum": "" }, "require": { @@ -12551,9 +13145,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.6.0" + "source": "https://github.com/laravel/telescope/tree/v5.10.2" }, - "time": "2025-03-17T20:24:10+00:00" + "time": "2025-07-24T05:26:13+00:00" }, { "name": "mockery/mockery", @@ -12640,16 +13234,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -12688,7 +13282,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -12696,42 +13290,43 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.7.0", + "version": "v8.8.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "586cb8181a257a2152b6a855ca8d9598878a1a26" + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/586cb8181a257a2152b6a855ca8d9598878a1a26", - "reference": "586cb8181a257a2152b6a855ca8d9598878a1a26", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", "shasum": "" }, "require": { - "filp/whoops": "^2.17.0", - "nunomaduro/termwind": "^2.3.0", + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", "php": "^8.2.0", - "symfony/console": "^7.2.1" + "symfony/console": "^7.3.0" }, "conflict": { - "laravel/framework": "<11.39.1 || >=13.0.0", - "phpunit/phpunit": "<11.5.3 || >=12.0.0" + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" }, "require-dev": { - "larastan/larastan": "^2.10.0", - "laravel/framework": "^11.44.2", - "laravel/pint": "^1.21.2", - "laravel/sail": "^1.41.0", - "laravel/sanctum": "^4.0.8", + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0", - "pestphp/pest": "^3.7.4", - "sebastian/environment": "^6.1.0 || ^7.2.0" + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2", + "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", "extra": { @@ -12794,25 +13389,25 @@ "type": "patreon" } ], - "time": "2025-03-14T22:37:40+00:00" + "time": "2025-06-25T02:12:12+00:00" }, { "name": "pestphp/pest", - "version": "v3.8.0", + "version": "v3.8.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "42e1b9f17fc2b2036701f4b968158264bde542d4" + "reference": "c6244a8712968dbac88eb998e7ff3b5caa556b0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/42e1b9f17fc2b2036701f4b968158264bde542d4", - "reference": "42e1b9f17fc2b2036701f4b968158264bde542d4", + "url": "https://api.github.com/repos/pestphp/pest/zipball/c6244a8712968dbac88eb998e7ff3b5caa556b0d", + "reference": "c6244a8712968dbac88eb998e7ff3b5caa556b0d", "shasum": "" }, "require": { "brianium/paratest": "^7.8.3", - "nunomaduro/collision": "^8.7.0", + "nunomaduro/collision": "^8.8.0", "nunomaduro/termwind": "^2.3.0", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.0", @@ -12894,7 +13489,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.0" + "source": "https://github.com/pestphp/pest/tree/v3.8.2" }, "funding": [ { @@ -12906,7 +13501,7 @@ "type": "github" } ], - "time": "2025-03-30T17:49:10+00:00" + "time": "2025-04-17T10:53:02+00:00" }, { "name": "pestphp/pest-plugin", @@ -12980,16 +13575,16 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v3.1.0", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "ebec636b97ee73936ee8485e15a59c3f5a4c21b2" + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/ebec636b97ee73936ee8485e15a59c3f5a4c21b2", - "reference": "ebec636b97ee73936ee8485e15a59c3f5a4c21b2", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa", "shasum": "" }, "require": { @@ -12998,7 +13593,7 @@ "ta-tikoma/phpunit-architecture-test": "^0.8.4" }, "require-dev": { - "pestphp/pest": "^3.7.5", + "pestphp/pest": "^3.8.1", "pestphp/pest-dev-tools": "^3.4.0" }, "type": "library", @@ -13034,7 +13629,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v3.1.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v3.1.1" }, "funding": [ { @@ -13046,7 +13641,7 @@ "type": "github" } ], - "time": "2025-03-30T17:28:50+00:00" + "time": "2025-04-16T22:59:48+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -13240,16 +13835,16 @@ }, { "name": "php-debugbar/php-debugbar", - "version": "v2.1.6", + "version": "v2.2.4", "source": { "type": "git", "url": "https://github.com/php-debugbar/php-debugbar.git", - "reference": "16fa68da5617220594aa5e33fa9de415f94784a0" + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/16fa68da5617220594aa5e33fa9de415f94784a0", - "reference": "16fa68da5617220594aa5e33fa9de415f94784a0", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35", "shasum": "" }, "require": { @@ -13257,6 +13852,9 @@ "psr/log": "^1|^2|^3", "symfony/var-dumper": "^4|^5|^6|^7" }, + "replace": { + "maximebf/debugbar": "self.version" + }, "require-dev": { "dbrekelmans/bdi": "^1", "phpunit/phpunit": "^8|^9", @@ -13271,7 +13869,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -13304,9 +13902,9 @@ ], "support": { "issues": "https://github.com/php-debugbar/php-debugbar/issues", - "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.1.6" + "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.4" }, - "time": "2025-02-21T17:47:03+00:00" + "time": "2025-07-22T14:01:30+00:00" }, { "name": "php-webdriver/webdriver", @@ -13376,16 +13974,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.11", + "version": "2.1.21", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30" + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8ca5f79a8f63c49b2359065832a654e1ec70ac30", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1ccf445757458c06a04eb3f803603cb118fe5fa6", + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6", "shasum": "" }, "require": { @@ -13430,20 +14028,20 @@ "type": "github" } ], - "time": "2025-03-24T13:45:00+00:00" + "time": "2025-07-28T19:35:08+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.9", + "version": "11.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7" + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/14d63fbcca18457e49c6f8bebaa91a87e8e188d7", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", "shasum": "" }, "require": { @@ -13500,15 +14098,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-02-25T13:26:39+00:00" + "time": "2025-06-18T08:56:18+00:00" }, { "name": "phpunit/php-file-iterator", @@ -13858,21 +14468,21 @@ }, { "name": "rector/rector", - "version": "2.0.11", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "059b827cc648929711606e9824337e41e2f9ed92" + "reference": "40a71441dd73fa150a66102f5ca1364c44fc8fff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/059b827cc648929711606e9824337e41e2f9ed92", - "reference": "059b827cc648929711606e9824337e41e2f9ed92", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/40a71441dd73fa150a66102f5ca1364c44fc8fff", + "reference": "40a71441dd73fa150a66102f5ca1364c44fc8fff", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.9" + "phpstan/phpstan": "^2.1.18" }, "conflict": { "rector/rector-doctrine": "*", @@ -13897,6 +14507,7 @@ "MIT" ], "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", "keywords": [ "automation", "dev", @@ -13905,7 +14516,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.0.11" + "source": "https://github.com/rectorphp/rector/tree/2.1.2" }, "funding": [ { @@ -13913,7 +14524,7 @@ "type": "github" } ], - "time": "2025-03-28T10:25:17+00:00" + "time": "2025-07-17T19:30:06+00:00" }, { "name": "sebastian/cli-parser", @@ -14292,23 +14903,23 @@ }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -14344,15 +14955,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", @@ -15257,16 +15880,16 @@ }, { "name": "symfony/http-client", - "version": "v7.2.4", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6" + "reference": "1c064a0c67749923483216b081066642751cc2c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", - "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1c064a0c67749923483216b081066642751cc2c7", + "reference": "1c064a0c67749923483216b081066642751cc2c7", "shasum": "" }, "require": { @@ -15278,6 +15901,7 @@ }, "conflict": { "amphp/amp": "<2.5", + "amphp/socket": "<1.1", "php-http/discovery": "<1.15", "symfony/http-foundation": "<6.4" }, @@ -15290,7 +15914,6 @@ "require-dev": { "amphp/http-client": "^4.2.1|^5.0", "amphp/http-tunnel": "^1.0|^2.0", - "amphp/socket": "^1.1", "guzzlehttp/promises": "^1.4|^2.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", @@ -15332,7 +15955,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.2.4" + "source": "https://github.com/symfony/http-client/tree/v7.3.2" }, "funding": [ { @@ -15343,25 +15966,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-13T10:27:23+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.5.2", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" + "reference": "75d7043853a42837e68111812f4d964b01e5101c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", - "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", "shasum": "" }, "require": { @@ -15374,7 +16001,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -15410,7 +16037,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" }, "funding": [ { @@ -15426,27 +16053,27 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:49:48+00:00" + "time": "2025-04-29T11:18:49+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "89f0dea1cb0f0d5744d3ec1764a286af5e006636" + "reference": "cf6fb197b676ba716837c886baca842e4db29005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/89f0dea1cb0f0d5744d3ec1764a286af5e006636", - "reference": "89f0dea1cb0f0d5744d3ec1764a286af5e006636", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", + "reference": "cf6fb197b676ba716837c886baca842e4db29005", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", "symfony/finder": "^6.4.0 || ^7.0.0" }, "require-dev": { @@ -15483,9 +16110,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.4" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" }, - "time": "2024-01-05T14:10:56+00:00" + "time": "2025-04-20T20:23:40+00:00" }, { "name": "theseer/tokenizer", diff --git a/conductor.json b/conductor.json new file mode 100644 index 000000000..851d13ed0 --- /dev/null +++ b/conductor.json @@ -0,0 +1,7 @@ +{ + "scripts": { + "setup": "./scripts/conductor-setup.sh", + "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down" + }, + "runScriptMode": "nonconcurrent" +} diff --git a/config/api.php b/config/api.php new file mode 100644 index 000000000..83bac8f03 --- /dev/null +++ b/config/api.php @@ -0,0 +1,5 @@ + env('API_RATE_LIMIT', 200), +]; diff --git a/config/constants.php b/config/constants.php index 2e54acade..1fc1af3f3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.419', - 'helper_version' => '1.0.8', - 'realtime_version' => '1.0.8', + 'version' => '4.0.0-beta.436', + 'helper_version' => '1.0.11', + 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), @@ -12,6 +12,7 @@ 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), + 'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json', ], 'urls' => [ @@ -22,7 +23,8 @@ 'services' => [ // Temporary disabled until cache is implemented // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json', - 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json', + 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json', + 'file_name' => 'service-templates-latest.json', ], 'terminal' => [ @@ -57,9 +59,16 @@ 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), + 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), + 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), + 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'connection_timeout' => 10, 'server_interval' => 20, - 'command_timeout' => 7200, + 'command_timeout' => 3600, + 'max_retries' => env('SSH_MAX_RETRIES', 3), + 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds + 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds + 'retry_multiplier' => env('SSH_RETRY_MULTIPLIER', 2), ], 'invitation' => [ @@ -69,6 +78,10 @@ ], ], + 'email_change' => [ + 'verification_code_expiry_minutes' => 10, + ], + 'sentry' => [ 'sentry_dsn' => env('SENTRY_DSN'), ], diff --git a/config/horizon.php b/config/horizon.php index 6086b30da..cdabcb1e8 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -182,14 +182,15 @@ 'defaults' => [ 's6' => [ 'connection' => 'redis', - 'queue' => ['high', 'default'], - 'balance' => env('HORIZON_BALANCE', 'auto'), - 'maxTime' => 0, - 'maxJobs' => 0, + 'balance' => env('HORIZON_BALANCE', 'false'), + 'queue' => env('HORIZON_QUEUES', 'high,default'), + 'maxTime' => 3600, + 'maxJobs' => 400, 'memory' => 128, 'tries' => 1, - 'timeout' => 3560, 'nice' => 0, + 'sleep' => 3, + 'timeout' => 3600, ], ], @@ -198,7 +199,7 @@ 's6' => [ 'autoScalingStrategy' => 'size', 'minProcesses' => env('HORIZON_MIN_PROCESSES', 1), - 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 6), + 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 4), 'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1), 'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1), ], @@ -208,7 +209,7 @@ 's6' => [ 'autoScalingStrategy' => 'size', 'minProcesses' => env('HORIZON_MIN_PROCESSES', 1), - 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 6), + 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 4), 'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1), 'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1), ], diff --git a/config/livewire.php b/config/livewire.php index 02725e944..bd3733076 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -90,7 +90,7 @@ | */ - 'legacy_model_binding' => true, + 'legacy_model_binding' => false, /* |--------------------------------------------------------------------------- diff --git a/config/logging.php b/config/logging.php index 4c3df4ce1..488327414 100644 --- a/config/logging.php +++ b/config/logging.php @@ -118,6 +118,20 @@ 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], + + 'scheduled' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scheduled.log'), + 'level' => 'debug', + 'days' => 1, + ], + + 'scheduled-errors' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scheduled-errors.log'), + 'level' => 'debug', + 'days' => 7, + ], ], ]; diff --git a/config/services.php b/config/services.php index d1c4a3699..6a21cda18 100644 --- a/config/services.php +++ b/config/services.php @@ -46,6 +46,13 @@ 'redirect' => env('AUTHENTIK_REDIRECT_URI'), ], + 'clerk' => [ + 'client_id' => env('CLERK_CLIENT_ID'), + 'client_secret' => env('CLERK_CLIENT_SECRET'), + 'redirect' => env('CLERK_REDIRECT_URI'), + 'base_url' => env('CLERK_BASE_URL'), + ], + 'google' => [ 'client_id' => env('GOOGLE_CLIENT_ID'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'), @@ -53,4 +60,11 @@ 'tenant' => env('GOOGLE_TENANT'), ], + 'zitadel' => [ + 'client_id' => env('ZITADEL_CLIENT_ID'), + 'client_secret' => env('ZITADEL_CLIENT_SECRET'), + 'redirect' => env('ZITADEL_REDIRECT_URI'), + 'base_url' => env('ZITADEL_BASE_URL'), + ], + ]; diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php new file mode 100644 index 000000000..26748c54e --- /dev/null +++ b/database/factories/TeamFactory.php @@ -0,0 +1,40 @@ + + */ +class TeamFactory extends Factory +{ + protected $model = Team::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->company().' Team', + 'description' => $this->faker->sentence(), + 'personal_team' => false, + 'show_boarding' => false, + ]; + } + + /** + * Indicate that the team is a personal team. + */ + public function personal(): static + { + return $this->state(fn (array $attributes) => [ + 'personal_team' => true, + 'name' => $this->faker->firstName()."'s Team", + ]); + } +} diff --git a/database/migrations/2025_05_26_100258_add_server_patch_notifications.php b/database/migrations/2025_05_26_100258_add_server_patch_notifications.php new file mode 100644 index 000000000..6d6611173 --- /dev/null +++ b/database/migrations/2025_05_26_100258_add_server_patch_notifications.php @@ -0,0 +1,74 @@ +boolean('server_patch_email_notifications')->default(true); + }); + + // Add server patch notification fields to discord notification settings + Schema::table('discord_notification_settings', function (Blueprint $table) { + $table->boolean('server_patch_discord_notifications')->default(true); + }); + + // Add server patch notification fields to telegram notification settings + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->boolean('server_patch_telegram_notifications')->default(true); + $table->string('telegram_notifications_server_patch_thread_id')->nullable(); + }); + + // Add server patch notification fields to slack notification settings + Schema::table('slack_notification_settings', function (Blueprint $table) { + $table->boolean('server_patch_slack_notifications')->default(true); + }); + + // Add server patch notification fields to pushover notification settings + Schema::table('pushover_notification_settings', function (Blueprint $table) { + $table->boolean('server_patch_pushover_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove server patch notification fields from email notification settings + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('server_patch_email_notifications'); + }); + + // Remove server patch notification fields from discord notification settings + Schema::table('discord_notification_settings', function (Blueprint $table) { + $table->dropColumn('server_patch_discord_notifications'); + }); + + // Remove server patch notification fields from telegram notification settings + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->dropColumn([ + 'server_patch_telegram_notifications', + 'telegram_notifications_server_patch_thread_id', + ]); + }); + + // Remove server patch notification fields from slack notification settings + Schema::table('slack_notification_settings', function (Blueprint $table) { + $table->dropColumn('server_patch_slack_notifications'); + }); + + // Remove server patch notification fields from pushover notification settings + Schema::table('pushover_notification_settings', function (Blueprint $table) { + $table->dropColumn('server_patch_pushover_notifications'); + }); + } +}; diff --git a/database/migrations/2025_05_29_100258_add_terminal_enabled_to_server_settings.php b/database/migrations/2025_05_29_100258_add_terminal_enabled_to_server_settings.php new file mode 100644 index 000000000..45db2f28b --- /dev/null +++ b/database/migrations/2025_05_29_100258_add_terminal_enabled_to_server_settings.php @@ -0,0 +1,28 @@ +boolean('is_terminal_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('is_terminal_enabled'); + }); + } +}; diff --git a/database/migrations/2025_06_06_073345_create_server_previous_ip.php b/database/migrations/2025_06_06_073345_create_server_previous_ip.php new file mode 100644 index 000000000..7f756c184 --- /dev/null +++ b/database/migrations/2025_06_06_073345_create_server_previous_ip.php @@ -0,0 +1,28 @@ +string('ip_previous')->nullable()->after('ip'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('ip_previous'); + }); + } +}; diff --git a/database/migrations/2025_06_16_123532_change_sentinel_on_by_default.php b/database/migrations/2025_06_16_123532_change_sentinel_on_by_default.php new file mode 100644 index 000000000..f4c6d4038 --- /dev/null +++ b/database/migrations/2025_06_16_123532_change_sentinel_on_by_default.php @@ -0,0 +1,28 @@ +boolean('is_sentinel_enabled')->default(true)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->boolean('is_sentinel_enabled')->default(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table.php b/database/migrations/2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table.php new file mode 100644 index 000000000..7307da953 --- /dev/null +++ b/database/migrations/2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_sponsorship_popup_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_sponsorship_popup_enabled'); + }); + } +}; diff --git a/database/migrations/2025_06_26_131350_optimize_activity_log_indexes.php b/database/migrations/2025_06_26_131350_optimize_activity_log_indexes.php new file mode 100644 index 000000000..4d4be1232 --- /dev/null +++ b/database/migrations/2025_06_26_131350_optimize_activity_log_indexes.php @@ -0,0 +1,44 @@ +>\'type_uuid\'), created_at DESC)'); + + // Add specific index for status queries on properties + DB::unprepared('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_activity_properties_status ON activity_log ((properties->>\'status\'))'); + + } catch (\Exception $e) { + Log::error('Error adding optimized indexes to activity_log: '.$e->getMessage()); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + try { + DB::unprepared('DROP INDEX CONCURRENTLY IF EXISTS idx_activity_type_uuid_created_at'); + DB::unprepared('DROP INDEX CONCURRENTLY IF EXISTS idx_activity_properties_status'); + } catch (\Exception $e) { + Log::error('Error dropping optimized indexes from activity_log: '.$e->getMessage()); + } + } +}; diff --git a/database/migrations/2025_07_14_191016_add_deleted_at_to_application_previews_table.php b/database/migrations/2025_07_14_191016_add_deleted_at_to_application_previews_table.php new file mode 100644 index 000000000..25aa0f5f0 --- /dev/null +++ b/database/migrations/2025_07_14_191016_add_deleted_at_to_application_previews_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2025_07_16_202201_add_timeout_to_scheduled_database_backups_table.php b/database/migrations/2025_07_16_202201_add_timeout_to_scheduled_database_backups_table.php new file mode 100644 index 000000000..f8f8cb8ad --- /dev/null +++ b/database/migrations/2025_07_16_202201_add_timeout_to_scheduled_database_backups_table.php @@ -0,0 +1,18 @@ +integer('timeout')->default(3600); + }); + } +}; diff --git a/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php new file mode 100644 index 000000000..db8a42fb7 --- /dev/null +++ b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('release_tag'); // GitHub tag_name (e.g., "v4.0.0-beta.420.6") + $table->timestamp('read_at'); + $table->timestamps(); + + $table->unique(['user_id', 'release_tag']); + $table->index('user_id'); + $table->index('release_tag'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_changelog_reads'); + } +}; diff --git a/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php b/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php new file mode 100644 index 000000000..e414472df --- /dev/null +++ b/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php @@ -0,0 +1,28 @@ +boolean('disable_local_backup')->default(false)->after('save_s3'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn('disable_local_backup'); + }); + } +}; diff --git a/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php new file mode 100644 index 000000000..9cefe2c09 --- /dev/null +++ b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php @@ -0,0 +1,30 @@ +string('pending_email')->nullable()->after('email'); + $table->string('email_change_code', 6)->nullable()->after('pending_email'); + $table->timestamp('email_change_code_expires_at')->nullable()->after('email_change_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['pending_email', 'email_change_code', 'email_change_code_expires_at']); + }); + } +}; diff --git a/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php b/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php new file mode 100644 index 000000000..32ed075ba --- /dev/null +++ b/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php @@ -0,0 +1,18 @@ +boolean('is_env_sorting_enabled')->default(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php b/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php new file mode 100644 index 000000000..399c49c7f --- /dev/null +++ b/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_git_shallow_clone_enabled')->default(true)->after('is_git_lfs_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('is_git_shallow_clone_enabled'); + }); + } +}; diff --git a/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php new file mode 100644 index 000000000..5d84ce42d --- /dev/null +++ b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php @@ -0,0 +1,28 @@ +boolean('is_pr_deployments_public_enabled')->default(false)->after('is_preview_deployments_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('is_pr_deployments_public_enabled'); + }); + } +}; diff --git a/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php new file mode 100644 index 000000000..31398bd35 --- /dev/null +++ b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php @@ -0,0 +1,28 @@ +dropColumn('is_readonly'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->boolean('is_readonly')->default(false); + }); + } +}; diff --git a/database/migrations/2025_09_10_173300_drop_webhooks_table.php b/database/migrations/2025_09_10_173300_drop_webhooks_table.php new file mode 100644 index 000000000..4cb1b4e70 --- /dev/null +++ b/database/migrations/2025_09_10_173300_drop_webhooks_table.php @@ -0,0 +1,31 @@ +id(); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->string('type'); + $table->longText('payload'); + $table->longText('failure_reason')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_10_173402_drop_kubernetes_table.php b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php new file mode 100644 index 000000000..329ed0e7e --- /dev/null +++ b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('uuid')->unique(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php new file mode 100644 index 000000000..076ee8e09 --- /dev/null +++ b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php @@ -0,0 +1,38 @@ +dropColumn('is_build_time'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_build_time column + if (! Schema::hasColumn('environment_variables', 'is_build_time')) { + $table->boolean('is_build_time')->default(false)->after('value'); + } + }); + } +}; diff --git a/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php new file mode 100644 index 000000000..d95f351d5 --- /dev/null +++ b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php @@ -0,0 +1,28 @@ +boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } +}; diff --git a/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php new file mode 100644 index 000000000..b78f391fc --- /dev/null +++ b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php @@ -0,0 +1,28 @@ +boolean('use_build_secrets')->default(false)->after('is_build_server_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('use_build_secrets'); + }); + } +}; diff --git a/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php new file mode 100644 index 000000000..6fd4bfed6 --- /dev/null +++ b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php @@ -0,0 +1,67 @@ +boolean('is_runtime')->default(true)->after('is_buildtime_only'); + $table->boolean('is_buildtime')->default(true)->after('is_runtime'); + }); + + // Migrate existing data from is_buildtime_only to new fields + DB::table('environment_variables') + ->where('is_buildtime_only', true) + ->update([ + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + DB::table('environment_variables') + ->where('is_buildtime_only', false) + ->update([ + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + // Remove the old is_buildtime_only column + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_buildtime_only column + $table->boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + + // Restore data to is_buildtime_only based on new fields + DB::table('environment_variables') + ->where('is_runtime', false) + ->where('is_buildtime', true) + ->update(['is_buildtime_only' => true]); + + DB::table('environment_variables') + ->where('is_runtime', true) + ->update(['is_buildtime_only' => false]); + + // Remove new columns + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn(['is_runtime', 'is_buildtime']); + }); + } +}; diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php new file mode 100644 index 000000000..e57354037 --- /dev/null +++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php @@ -0,0 +1,32 @@ +string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + // Optionally, update any existing rows with the old default to the new one + DB::table('standalone_clickhouses') + ->where('image', 'bitnami/clickhouse') + ->update(['image' => 'bitnamilegacy/clickhouse']); + } + + public function down() + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnami/clickhouse')->change(); + }); + // Optionally, revert any changed values + DB::table('standalone_clickhouses') + ->where('image', 'bitnamilegacy/clickhouse') + ->update(['image' => 'bitnami/clickhouse']); + } +}; diff --git a/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php new file mode 100644 index 000000000..d80f2621b --- /dev/null +++ b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php @@ -0,0 +1,28 @@ +boolean('s3_uploaded')->nullable()->after('filename'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { + $table->dropColumn('s3_uploaded'); + }); + } +}; diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php new file mode 100644 index 000000000..2c92b0e19 --- /dev/null +++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('provider'); + $table->text('token'); + $table->string('name')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_provider_tokens'); + } +}; diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php new file mode 100644 index 000000000..b1c9ec48b --- /dev/null +++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php @@ -0,0 +1,28 @@ +bigInteger('hetzner_server_id')->nullable()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_id'); + }); + } +}; diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php new file mode 100644 index 000000000..a25a4ce83 --- /dev/null +++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php @@ -0,0 +1,29 @@ +foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['cloud_provider_token_id']); + $table->dropColumn('cloud_provider_token_id'); + }); + } +}; diff --git a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php new file mode 100644 index 000000000..d94c9c76f --- /dev/null +++ b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php @@ -0,0 +1,28 @@ +string('hetzner_server_status')->nullable()->after('hetzner_server_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_status'); + }); + } +}; diff --git a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php new file mode 100644 index 000000000..ddb655d2c --- /dev/null +++ b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php @@ -0,0 +1,28 @@ +boolean('is_validating')->default(false)->after('hetzner_server_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('is_validating'); + }); + } +}; diff --git a/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php new file mode 100644 index 000000000..fe216a57d --- /dev/null +++ b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('script'); // Encrypted in the model + $table->timestamps(); + + $table->index('team_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_init_scripts'); + } +}; diff --git a/database/migrations/2025_10_10_120000_create_webhook_notification_settings_table.php b/database/migrations/2025_10_10_120000_create_webhook_notification_settings_table.php new file mode 100644 index 000000000..a3edacbf9 --- /dev/null +++ b/database/migrations/2025_10_10_120000_create_webhook_notification_settings_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + + $table->boolean('webhook_enabled')->default(false); + $table->text('webhook_url')->nullable(); + + $table->boolean('deployment_success_webhook_notifications')->default(false); + $table->boolean('deployment_failure_webhook_notifications')->default(true); + $table->boolean('status_change_webhook_notifications')->default(false); + $table->boolean('backup_success_webhook_notifications')->default(false); + $table->boolean('backup_failure_webhook_notifications')->default(true); + $table->boolean('scheduled_task_success_webhook_notifications')->default(false); + $table->boolean('scheduled_task_failure_webhook_notifications')->default(true); + $table->boolean('docker_cleanup_success_webhook_notifications')->default(false); + $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true); + $table->boolean('server_disk_usage_webhook_notifications')->default(true); + $table->boolean('server_reachable_webhook_notifications')->default(false); + $table->boolean('server_unreachable_webhook_notifications')->default(true); + $table->boolean('server_patch_webhook_notifications')->default(false); + + $table->unique(['team_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_notification_settings'); + } +}; diff --git a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php new file mode 100644 index 000000000..de2707557 --- /dev/null +++ b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php @@ -0,0 +1,47 @@ +get(); + + foreach ($teams as $team) { + DB::table('webhook_notification_settings')->updateOrInsert( + ['team_id' => $team->id], + [ + 'webhook_enabled' => false, + 'webhook_url' => null, + 'deployment_success_webhook_notifications' => false, + 'deployment_failure_webhook_notifications' => true, + 'status_change_webhook_notifications' => false, + 'backup_success_webhook_notifications' => false, + 'backup_failure_webhook_notifications' => true, + 'scheduled_task_success_webhook_notifications' => false, + 'scheduled_task_failure_webhook_notifications' => true, + 'docker_cleanup_success_webhook_notifications' => false, + 'docker_cleanup_failure_webhook_notifications' => true, + 'server_disk_usage_webhook_notifications' => true, + 'server_reachable_webhook_notifications' => false, + 'server_unreachable_webhook_notifications' => true, + 'server_patch_webhook_notifications' => false, + ] + ); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // We don't need to do anything in down() since the webhook_notification_settings + // table will be dropped by the create migration's down() method + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 2d6f52e31..f012c1534 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -14,6 +14,21 @@ class ApplicationSeeder extends Seeder */ public function run(): void { + Application::create([ + 'name' => 'Docker Compose Example', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/docker-compose', + 'docker_compose_location' => 'docker-compose-test.yaml', + 'build_pack' => 'dockercompose', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); Application::create([ 'name' => 'NodeJS Fastify Example', 'fqdn' => 'http://nodejs.127.0.0.1.sslip.io', diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php index 09f6cc984..1b71a5e43 100644 --- a/database/seeders/CaSslCertSeeder.php +++ b/database/seeders/CaSslCertSeeder.php @@ -4,7 +4,6 @@ use App\Helpers\SslHelper; use App\Models\Server; -use App\Models\SslCertificate; use Illuminate\Database\Seeder; class CaSslCertSeeder extends Seeder @@ -13,7 +12,7 @@ public function run() { Server::chunk(200, function ($servers) { foreach ($servers as $server) { - $existingCaCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first(); + $existingCaCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); if (! $existingCaCert) { $caCert = SslHelper::generateSslCertificate( diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e0e7a3ba5..57ccab4ae 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,7 @@ public function run(): void DisableTwoStepConfirmationSeeder::class, SentinelSeeder::class, CaSslCertSeeder::class, + PersonalAccessTokenSeeder::class, ]); } } diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php index 7f2deb3a6..baa7abffc 100644 --- a/database/seeders/InstanceSettingsSeeder.php +++ b/database/seeders/InstanceSettingsSeeder.php @@ -16,6 +16,7 @@ public function run(): void InstanceSettings::create([ 'id' => 0, 'is_registration_enabled' => true, + 'is_api_enabled' => isDev(), 'smtp_enabled' => true, 'smtp_host' => 'coolify-mail', 'smtp_port' => 1025, diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index fa692d2dc..2e5e6fcc4 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -17,11 +17,14 @@ public function run(): void $providers = collect([ 'azure', 'bitbucket', + 'clerk', + 'discord', 'github', 'gitlab', 'google', 'authentik', 'infomaniak', + 'zitadel', ]); $isOauthSeeded = OauthSetting::count() > 0; diff --git a/database/seeders/PersonalAccessTokenSeeder.php b/database/seeders/PersonalAccessTokenSeeder.php new file mode 100644 index 000000000..38a45219c --- /dev/null +++ b/database/seeders/PersonalAccessTokenSeeder.php @@ -0,0 +1,115 @@ +environment('production')) { + $this->command->warn('Skipping PersonalAccessTokenSeeder in production environment'); + + return; + } + + // Get the first user (usually the admin user created during setup) + $user = User::find(0); + + if (! $user) { + $this->command->warn('No user found. Please run UserSeeder first.'); + + return; + } + + // Get the user's first team + $team = $user->teams()->first(); + + if (! $team) { + $this->command->warn('No team found for user. Cannot create API tokens.'); + + return; + } + + // Define test tokens with different scopes + $testTokens = [ + [ + 'name' => 'Development Root Token', + 'token' => 'root', + 'abilities' => ['root'], + ], + [ + 'name' => 'Development Read Token', + 'token' => 'read', + 'abilities' => ['read'], + ], + [ + 'name' => 'Development Read Sensitive Token', + 'token' => 'read-sensitive', + 'abilities' => ['read', 'read:sensitive'], + ], + [ + 'name' => 'Development Write Token', + 'token' => 'write', + 'abilities' => ['write'], + ], + [ + 'name' => 'Development Write Sensitive Token', + 'token' => 'write-sensitive', + 'abilities' => ['write', 'write:sensitive'], + ], + [ + 'name' => 'Development Deploy Token', + 'token' => 'deploy', + 'abilities' => ['deploy'], + ], + ]; + + // First, remove all existing development tokens for this user + $deletedCount = PersonalAccessToken::where('tokenable_id', $user->id) + ->where('tokenable_type', get_class($user)) + ->whereIn('name', array_column($testTokens, 'name')) + ->delete(); + + if ($deletedCount > 0) { + $this->command->info("Removed {$deletedCount} existing development token(s)."); + } + + // Now create fresh tokens + foreach ($testTokens as $tokenData) { + // Create the token with a simple format: Bearer {scope} + // The token format in the database is the hash of the plain text token + $plainTextToken = $tokenData['token']; + + PersonalAccessToken::create([ + 'tokenable_type' => get_class($user), + 'tokenable_id' => $user->id, + 'name' => $tokenData['name'], + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $tokenData['abilities'], + 'team_id' => $team->id, + ]); + + $this->command->info("Created token '{$tokenData['name']}' with Bearer token: {$plainTextToken}"); + } + + $this->command->info(''); + $this->command->info('Test API tokens created successfully!'); + $this->command->info('You can use these tokens in development as:'); + $this->command->info(' Bearer root - Root access'); + $this->command->info(' Bearer read - Read only access'); + $this->command->info(' Bearer read-sensitive - Read with sensitive data access'); + $this->command->info(' Bearer write - Write access'); + $this->command->info(' Bearer write-sensitive - Write with sensitive data access'); + $this->command->info(' Bearer deploy - Deploy access'); + } +} diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 058d4c8e4..adada458e 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -2,9 +2,12 @@ namespace Database\Seeders; +use App\Actions\Proxy\CheckProxy; +use App\Actions\Proxy\StartProxy; use App\Data\ServerMetadata; use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; @@ -115,11 +118,20 @@ public function run(): void $server->settings->is_reachable = true; $server->settings->is_usable = true; $server->settings->save(); + StartProxy::dispatch($server); + CheckAndStartSentinelJob::dispatch($server); } else { $server = Server::find(0); $server->settings->is_reachable = true; $server->settings->is_usable = true; $server->settings->save(); + $shouldStart = CheckProxy::run($server); + if ($shouldStart) { + StartProxy::dispatch($server); + } + if ($server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($server); + } } if (StandaloneDocker::find(0) == null) { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3fadd914c..fee17dad6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,7 @@ services: coolify: + image: coolify:dev + pull_policy: never build: context: . dockerfile: ./docker/development/Dockerfile @@ -41,6 +43,8 @@ services: volumes: - dev_redis_data:/data soketi: + image: coolify-realtime:dev + pull_policy: never build: context: . dockerfile: ./docker/coolify-realtime/Dockerfile @@ -59,8 +63,9 @@ services: SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] vite: - image: node:20-alpine + image: node:24-alpine pull_policy: always + container_name: coolify-vite working_dir: /var/www/html environment: VITE_HOST: "${VITE_HOST:-localhost}" @@ -73,6 +78,8 @@ services: networks: - coolify testing-host: + image: coolify-testing-host:dev + pull_policy: never build: context: . dockerfile: ./docker/testing-host/Dockerfile diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 965fca276..b90f126a2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -61,7 +61,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.8' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 519309e39..cd4a307aa 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -103,7 +103,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.6' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index b62469cef..212703798 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -4,15 +4,16 @@ ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ ARG DOCKER_VERSION=28.0.0 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.34.0 +ARG DOCKER_COMPOSE_VERSION=2.38.2 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.22.0 +ARG DOCKER_BUILDX_VERSION=0.25.0 # https://github.com/buildpacks/pack/releases -ARG PACK_VERSION=0.37.0 +ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases -ARG NIXPACKS_VERSION=1.34.1 +ARG NIXPACKS_VERSION=1.40.0 # https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-03-12T17-29-24Z +ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z + FROM minio/mc:${MINIO_VERSION} AS minio-client diff --git a/docker/coolify-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile index 3a53da490..18c2f9301 100644 --- a/docker/coolify-realtime/Dockerfile +++ b/docker/coolify-realtime/Dockerfile @@ -2,7 +2,7 @@ # https://github.com/soketi/soketi/releases ARG SOKETI_VERSION=1.6-16-alpine # https://github.com/cloudflare/cloudflared/releases -ARG CLOUDFLARED_VERSION=2025.2.0 +ARG CLOUDFLARED_VERSION=2025.7.0 FROM quay.io/soketi/soketi:${SOKETI_VERSION} diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 1c329e47f..c445c972c 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "axios": "1.8.4", + "axios": "1.12.0", "cookie": "1.0.2", "dotenv": "16.5.0", "node-pty": "1.0.0", @@ -36,13 +36,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -181,14 +181,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -323,9 +324,9 @@ } }, "node_modules/nan": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.1.tgz", - "integrity": "sha512-pfRR4ZcNTSm2ZFHaztuvbICf+hyiG6ecA06SfAxoPmuHjvMu0KUIae7Y8GyVkbBqeEIidsmXeYooWIX9+qjfRQ==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", "license": "MIT" }, "node_modules/node-pty": { diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 7851d7f4d..aec3dbe3d 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -5,7 +5,7 @@ "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", "cookie": "1.0.2", - "axios": "1.8.4", + "axios": "1.12.0", "dotenv": "16.5.0", "node-pty": "1.0.0", "ws": "8.18.1" diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 61dd29453..2607d2aec 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -265,11 +265,57 @@ function extractTimeout(commandString) { function extractSshArgs(commandString) { const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); - let sshArgs = sshCommandMatch ? sshCommandMatch[1].split(' ') : []; + if (!sshCommandMatch) return []; + + const argsString = sshCommandMatch[1]; + let sshArgs = []; + + // Parse shell arguments respecting quotes + let current = ''; + let inQuotes = false; + let quoteChar = ''; + let i = 0; + + while (i < argsString.length) { + const char = argsString[i]; + const nextChar = argsString[i + 1]; + + if (!inQuotes && (char === '"' || char === "'")) { + // Starting a quoted section + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + // Ending a quoted section + inQuotes = false; + current += char; + quoteChar = ''; + } else if (!inQuotes && char === ' ') { + // Space outside quotes - end of argument + if (current.trim()) { + sshArgs.push(current.trim()); + current = ''; + } + } else { + // Regular character + current += char; + } + i++; + } + + // Add final argument if exists + if (current.trim()) { + sshArgs.push(current.trim()); + } + + // Replace RequestTTY=no with RequestTTY=yes sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); - if (!sshArgs.includes('RequestTTY=yes')) { + + // Add RequestTTY=yes if not present + if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { sshArgs.push('-o', 'RequestTTY=yes'); } + return sshArgs; } diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 8c5beec07..85cce14d7 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -2,9 +2,9 @@ # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine # https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-03-12T17-29-24Z +ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z # https://github.com/cloudflare/cloudflared/releases -ARG CLOUDFLARED_VERSION=2025.2.0 +ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ ARG POSTGRES_VERSION=15 diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 5633170e3..628fb5054 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -2,9 +2,9 @@ # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine # https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-03-12T17-29-24Z +ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z # https://github.com/cloudflare/cloudflared/releases -ARG CLOUDFLARED_VERSION=2025.2.0 +ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ ARG POSTGRES_VERSION=15 @@ -72,6 +72,7 @@ RUN apk add --no-cache gnupg && \ curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg # Install system dependencies +RUN apk upgrade RUN apk add --no-cache \ postgresql${POSTGRES_VERSION}-client \ openssh-client \ @@ -120,6 +121,7 @@ COPY --chown=www-data:www-data templates ./templates COPY --chown=www-data:www-data resources/views ./resources/views COPY --chown=www-data:www-data artisan artisan COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml +COPY --chown=www-data:www-data changelogs/ ./changelogs/ RUN composer dump-autoload diff --git a/docker/testing-host/Dockerfile b/docker/testing-host/Dockerfile index b19d0875c..fdad3cc41 100644 --- a/docker/testing-host/Dockerfile +++ b/docker/testing-host/Dockerfile @@ -2,9 +2,9 @@ # https://download.docker.com/linux/static/stable/ ARG DOCKER_VERSION=28.0.0 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.34.0 +ARG DOCKER_COMPOSE_VERSION=2.38.2 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.22.0 +ARG DOCKER_BUILDX_VERSION=0.25.0 FROM debian:12-slim diff --git a/hooks/pre-commit b/hooks/pre-commit index 029f67917..fc96e9766 100644 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then exec غير مستحسن، لأن خوادم Let's Encrypt مع هذا النطاق العام محدودة المعدل (ستفشل عملية التحقق من شهادة SSL).

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

      Əvəzində öz domeninizdən istifadə edin." -} +} \ No newline at end of file diff --git a/lang/cs.json b/lang/cs.json index 270fd272b..9e5d2c44e 100644 --- a/lang/cs.json +++ b/lang/cs.json @@ -2,13 +2,16 @@ "auth.login": "Přihlásit se", "auth.login.azure": "Přihlásit se pomocí Microsoftu", "auth.login.bitbucket": "Přihlásit se pomocí Bitbucketu", + "auth.login.clerk": "Přihlásit se pomocí Clerk", + "auth.login.discord": "Přihlásit se pomocí Discordu", "auth.login.github": "Přihlásit se pomocí GitHubu", "auth.login.gitlab": "Přihlásit se pomocí Gitlabu", "auth.login.google": "Přihlásit se pomocí Google", "auth.login.infomaniak": "Přihlásit se pomocí Infomaniak", "auth.already_registered": "Již jste registrováni?", "auth.confirm_password": "Potvrďte heslo", - "auth.forgot_password": "Zapomněli jste heslo", + "auth.forgot_password_link": "Zapomněli jste heslo?", + "auth.forgot_password_heading": "Obnovení hesla", "auth.forgot_password_send_email": "Poslat e-mail pro resetování hesla", "auth.register_now": "Registrovat se", "auth.logout": "Odhlásit se", @@ -28,4 +31,4 @@ "input.recovery_code": "Obnovovací kód", "button.save": "Uložit", "repository.url": "Příklady
      Pro veřejné repozitáře, použijte https://....
      Pro soukromé repozitáře, použijte git@....

      https://github.com/coollabsio/coolify-examples main branch bude zvolena
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch bude vybrána.
      https://gitea.com/sedlav/expressjs.git main branch vybrána.
      https://gitlab.com/andrasbacsai/nodejs-example.git main branch bude vybrána." -} +} \ No newline at end of file diff --git a/lang/de.json b/lang/de.json index c5644e3a7..fd587de22 100644 --- a/lang/de.json +++ b/lang/de.json @@ -2,13 +2,17 @@ "auth.login": "Anmelden", "auth.login.azure": "Mit Microsoft anmelden", "auth.login.bitbucket": "Mit Bitbucket anmelden", + "auth.login.clerk": "Mit Clerk anmelden", + "auth.login.discord": "Mit Discord anmelden", "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", + "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", "auth.confirm_password": "Passwort bestätigen", - "auth.forgot_password": "Passwort vergessen", + "auth.forgot_password_link": "Passwort vergessen?", + "auth.forgot_password_heading": "Passwort-Wiederherstellung", "auth.forgot_password_send_email": "Passwort zurücksetzen E-Mail senden", "auth.register_now": "Registrieren", "auth.logout": "Abmelden", @@ -28,4 +32,4 @@ "input.recovery_code": "Wiederherstellungscode", "button.save": "Speichern", "repository.url": "Beispiele
      Für öffentliche Repositories benutze https://....
      Für private Repositories benutze git@....

      https://github.com/coollabsio/coolify-examples main Branch wird ausgewählt
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify Branch wird ausgewählt.
      https://gitea.com/sedlav/expressjs.git main Branch wird ausgewählt.
      https://gitlab.com/andrasbacsai/nodejs-example.git main Branch wird ausgewählt." -} +} \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index cdca68601..a81e1ee68 100644 --- a/lang/en.json +++ b/lang/en.json @@ -3,13 +3,17 @@ "auth.login.authentik": "Login with Authentik", "auth.login.azure": "Login with Microsoft", "auth.login.bitbucket": "Login with Bitbucket", + "auth.login.clerk": "Login with Clerk", + "auth.login.discord": "Login with Discord", "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", "auth.login.infomaniak": "Login with Infomaniak", + "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", "auth.confirm_password": "Confirm password", - "auth.forgot_password": "Forgot password", + "auth.forgot_password_link": "Forgot password?", + "auth.forgot_password_heading": "Password recovery", "auth.forgot_password_send_email": "Send password reset email", "auth.register_now": "Register", "auth.logout": "Logout", @@ -19,7 +23,7 @@ "auth.failed": "These credentials do not match our records.", "auth.failed.callback": "Failed to process callback from login provider.", "auth.failed.password": "The provided password is incorrect.", - "auth.failed.email": "We can't find a user with that e-mail address.", + "auth.failed.email": "If an account exists with this email address, you will receive a password reset link shortly.", "auth.throttle": "Too many login attempts. Please try again in :seconds seconds.", "input.name": "Name", "input.email": "Email", @@ -37,4 +41,4 @@ "resource.delete_configurations": "Permanently delete all configuration files from the server.", "database.delete_backups_locally": "All backups will be permanently deleted from local storage.", "warning.sslipdomain": "Your configuration is saved, but sslip domain with https is NOT recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail).

      Use your own domain instead." -} +} \ No newline at end of file diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 000000000..1a4611d0d --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'If an account exists with this email address, you will receive a password reset link shortly.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => 'If an account exists with this email address, you will receive a password reset link shortly.', + +]; diff --git a/lang/es.json b/lang/es.json index aceacd462..f56387f05 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2,13 +2,16 @@ "auth.login": "Iniciar Sesión", "auth.login.azure": "Acceder con Microsoft", "auth.login.bitbucket": "Acceder con Bitbucket", + "auth.login.clerk": "Acceder con Clerk", + "auth.login.discord": "Acceder con Discord", "auth.login.github": "Acceder con GitHub", "auth.login.gitlab": "Acceder con Gitlab", "auth.login.google": "Acceder con Google", "auth.login.infomaniak": "Acceder con Infomaniak", "auth.already_registered": "¿Ya estás registrado?", "auth.confirm_password": "Confirmar contraseña", - "auth.forgot_password": "¿Olvidaste tu contraseña?", + "auth.forgot_password_link": "¿Olvidaste tu contraseña?", + "auth.forgot_password_heading": "Recuperación de contraseña", "auth.forgot_password_send_email": "Enviar correo de recuperación de contraseña", "auth.register_now": "Registrar", "auth.logout": "Cerrar sesión", @@ -28,4 +31,4 @@ "input.recovery_code": "Código de recuperación", "button.save": "Guardar", "repository.url": "Examples
      Para repositorios públicos, usar https://....
      Para repositorios privados, usar git@....

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

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

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

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

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

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

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

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

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

      Use seu próprio domínio em vez disso." -} +} \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index c5f393e65..08ad19df3 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -2,13 +2,16 @@ "auth.login": "Entrar", "auth.login.azure": "Entrar com Microsoft", "auth.login.bitbucket": "Entrar com Bitbucket", + "auth.login.clerk": "Entrar com Clerk", + "auth.login.discord": "Entrar com Discord", "auth.login.github": "Entrar com GitHub", "auth.login.gitlab": "Entrar com Gitlab", "auth.login.google": "Entrar com Google", "auth.login.infomaniak": "Entrar com Infomaniak", "auth.already_registered": "Já tem uma conta?", "auth.confirm_password": "Confirmar senha", - "auth.forgot_password": "Esqueceu a senha?", + "auth.forgot_password_link": "Esqueceu a senha?", + "auth.forgot_password_heading": "Recuperação de senha", "auth.forgot_password_send_email": "Enviar e-mail de redefinição de senha", "auth.register_now": "Cadastrar-se", "auth.logout": "Sair", @@ -28,4 +31,4 @@ "input.recovery_code": "Código de recuperação", "button.save": "Salvar", "repository.url": "Exemplos
      Para repositórios públicos, use https://....
      Para repositórios privados, use git@....

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

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

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

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

      https://github.com/coollabsio/coolify-examples main 分支將被選擇
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify 分支將被選擇。
      https://gitea.com/sedlav/expressjs.git main 分支將被選擇。
      https://gitlab.com/andrasbacsai/nodejs-example.git main 分支將被選擇。" -} +} \ No newline at end of file diff --git a/openapi.json b/openapi.json index 40f663e51..3667cbe87 100644 --- a/openapi.json +++ b/openapi.json @@ -353,6 +353,14 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -381,6 +389,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -701,6 +763,14 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -729,6 +799,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1049,6 +1173,14 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1077,6 +1209,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1326,6 +1512,14 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1354,6 +1548,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1586,6 +1834,14 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1614,6 +1870,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1685,6 +1995,14 @@ "type": "boolean", "nullable": true, "description": "Use build server." + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1713,6 +2031,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -2147,6 +2519,14 @@ "type": "boolean", "nullable": true, "description": "Use build server." + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -2178,6 +2558,60 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -2339,10 +2773,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2436,10 +2866,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2538,10 +2964,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2887,6 +3309,55 @@ ] } }, + "\/databases\/{uuid}\/backups": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get", + "description": "Get backups details by database UUID.", + "operationId": "get-database-backups-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get all backups for a database", + "content": { + "application\/json": { + "schema": { + "type": "string" + }, + "example": "Content is very complex. Will be implemented later." + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}": { "get": { "tags": [ @@ -3227,6 +3698,206 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete backup configuration", + "description": "Deletes a backup configuration and all its executions.", + "operationId": "delete-backup-configuration-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration to delete", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "delete_s3", + "in": "query", + "description": "Whether to delete all backup files from S3", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Backup configuration deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup configuration and all executions deleted." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup configuration not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup configuration not found." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update", + "description": "Update a specific backup configuration for a given database, identified by its UUID and the backup ID", + "operationId": "update-database-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Database backup configuration data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "save_s3": { + "type": "boolean", + "description": "Whether data is saved in s3 or not" + }, + "s3_storage_uuid": { + "type": "string", + "description": "S3 storage UUID" + }, + "backup_now": { + "type": "boolean", + "description": "Whether to take a backup now or not" + }, + "enabled": { + "type": "boolean", + "description": "Whether the backup is enabled or not" + }, + "databases_to_backup": { + "type": "string", + "description": "Comma separated list of databases to backup" + }, + "dump_all": { + "type": "boolean", + "description": "Whether all databases are dumped or not" + }, + "frequency": { + "type": "string", + "description": "Frequency of the backup" + }, + "database_backup_retention_amount_locally": { + "type": "integer", + "description": "Retention amount of the backup locally" + }, + "database_backup_retention_days_locally": { + "type": "integer", + "description": "Retention days of the backup locally" + }, + "database_backup_retention_max_storage_locally": { + "type": "integer", + "description": "Max storage of the backup locally" + }, + "database_backup_retention_amount_s3": { + "type": "integer", + "description": "Retention amount of the backup in s3" + }, + "database_backup_retention_days_s3": { + "type": "integer", + "description": "Retention days of the backup in s3" + }, + "database_backup_retention_max_storage_s3": { + "type": "integer", + "description": "Max storage of the backup in S3" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database backup configuration updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -3368,6 +4039,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -3493,6 +4167,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -3614,6 +4291,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -3739,6 +4419,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -3864,6 +4547,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -4001,6 +4687,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -4138,6 +4827,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -4263,6 +4955,178 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete backup execution", + "description": "Deletes a specific backup execution.", + "operationId": "delete-backup-execution-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "execution_uuid", + "in": "path", + "description": "UUID of the backup execution to delete", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "delete_s3", + "in": "query", + "description": "Whether to delete the backup from S3", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Backup execution deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup execution deleted." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup execution not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup execution not found." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List backup executions", + "description": "Get all executions for a specific backup configuration.", + "operationId": "list-backup-executions", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of backup executions", + "content": { + "application\/json": { + "schema": { + "properties": { + "executions": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup configuration not found." } }, "security": [ @@ -4673,6 +5537,477 @@ ] } }, + "\/github-apps": { + "post": { + "tags": [ + "GitHub Apps" + ], + "summary": "Create GitHub App", + "description": "Create a new GitHub app.", + "operationId": "create-github-app", + "requestBody": { + "description": "GitHub app creation payload.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "api_url", + "html_url", + "app_id", + "installation_id", + "client_id", + "client_secret", + "private_key_uuid" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the GitHub app." + }, + "organization": { + "type": "string", + "nullable": true, + "description": "Organization to associate the app with." + }, + "api_url": { + "type": "string", + "description": "API URL for the GitHub app (e.g., https:\/\/api.github.com)." + }, + "html_url": { + "type": "string", + "description": "HTML URL for the GitHub app (e.g., https:\/\/github.com)." + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH access (default: git)." + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH access (default: 22)." + }, + "app_id": { + "type": "integer", + "description": "GitHub App ID from GitHub." + }, + "installation_id": { + "type": "integer", + "description": "GitHub Installation ID." + }, + "client_id": { + "type": "string", + "description": "GitHub OAuth App Client ID." + }, + "client_secret": { + "type": "string", + "description": "GitHub OAuth App Client Secret." + }, + "webhook_secret": { + "type": "string", + "description": "Webhook secret for GitHub webhooks." + }, + "private_key_uuid": { + "type": "string", + "description": "UUID of an existing private key for GitHub App authentication." + }, + "is_system_wide": { + "type": "boolean", + "description": "Is this app system-wide (cloud only)." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "GitHub app created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "type": "string", + "nullable": true + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "app_id": { + "type": "integer" + }, + "installation_id": { + "type": "integer" + }, + "client_id": { + "type": "string" + }, + "private_key_id": { + "type": "integer" + }, + "is_system_wide": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}\/repositories": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "Load Repositories for a GitHub App", + "description": "Fetch repositories from GitHub for a given GitHub app.", + "operationId": "load-repositories", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Repositories loaded successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "repositories": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}\/repositories\/{owner}\/{repo}\/branches": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "Load Branches for a GitHub Repository", + "description": "Fetch branches from GitHub for a given repository.", + "operationId": "load-branches", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "owner", + "in": "path", + "description": "Repository owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "description": "Repository name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Branches loaded successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "branches": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}": { + "delete": { + "tags": [ + "GitHub Apps" + ], + "summary": "Delete GitHub App", + "description": "Delete a GitHub app if it's not being used by any applications.", + "operationId": "deleteGithubApp", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "GitHub app deleted successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitHub app deleted successfully" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitHub app not found" + }, + "409": { + "description": "Conflict - GitHub app is in use", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "This GitHub app is being used by 5 application(s). Please delete all applications first." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "GitHub Apps" + ], + "summary": "Update GitHub App", + "description": "Update an existing GitHub app.", + "operationId": "updateGithubApp", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "GitHub App name" + }, + "organization": { + "type": "string", + "nullable": true, + "description": "GitHub organization" + }, + "api_url": { + "type": "string", + "description": "GitHub API URL" + }, + "html_url": { + "type": "string", + "description": "GitHub HTML URL" + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH" + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH" + }, + "app_id": { + "type": "integer", + "description": "GitHub App ID" + }, + "installation_id": { + "type": "integer", + "description": "GitHub Installation ID" + }, + "client_id": { + "type": "string", + "description": "GitHub Client ID" + }, + "client_secret": { + "type": "string", + "description": "GitHub Client Secret" + }, + "webhook_secret": { + "type": "string", + "description": "GitHub Webhook Secret" + }, + "private_key_uuid": { + "type": "string", + "description": "Private key UUID" + }, + "is_system_wide": { + "type": "boolean", + "description": "Is system wide (non-cloud instances only)" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "GitHub app updated successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitHub app updated successfully" + }, + "data": { + "type": "object", + "description": "Updated GitHub app data" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitHub app not found" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/version": { "get": { "summary": "Version", @@ -4682,7 +6017,7 @@ "200": { "description": "Returns the version of the application", "content": { - "application\/json": { + "text\/html": { "schema": { "type": "string" }, @@ -4817,7 +6152,7 @@ "200": { "description": "Healthcheck endpoint.", "content": { - "application\/json": { + "text\/html": { "schema": { "type": "string" }, @@ -4923,6 +6258,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5022,6 +6360,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5103,6 +6444,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5159,6 +6503,202 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List Environments", + "description": "List all environments in a project.", + "operationId": "get-environments", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of environments", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Environment" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Projects" + ], + "summary": "Create Environment", + "description": "Create environment in project.", + "operationId": "create-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Environment created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "env123", + "description": "The UUID of the environment." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + }, + "409": { + "description": "Environment with this name already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments\/{environment_name_or_uuid}": { + "delete": { + "tags": [ + "Projects" + ], + "summary": "Delete Environment", + "description": "Delete environment by name or UUID. Environment must be empty.", + "operationId": "delete-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "description": "Environment has resources, so it cannot be deleted." + }, + "404": { + "description": "Project or environment not found." + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5290,6 +6830,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5351,6 +6894,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5605,6 +7151,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5704,6 +7253,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5803,6 +7355,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -5984,6 +7539,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -6213,6 +7771,9 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -6384,13 +7945,6 @@ "content": { "application\/json": { "schema": { - "required": [ - "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid", - "docker_compose_raw" - ], "properties": { "name": { "type": "string", @@ -6471,6 +8025,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -6568,10 +8125,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -6615,6 +8168,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -6665,10 +8221,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -6712,6 +8264,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -6767,10 +8322,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -6818,6 +8369,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" } }, "security": [ @@ -7017,6 +8571,15 @@ "type": "string", "format": "uuid" } + }, + { + "name": "latest", + "in": "query", + "description": "Pull latest images.", + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -7755,9 +9318,6 @@ "resourceable_id": { "type": "integer" }, - "is_build_time": { - "type": "boolean" - }, "is_literal": { "type": "boolean" }, @@ -7767,6 +9327,12 @@ "is_preview": { "type": "boolean" }, + "is_runtime": { + "type": "boolean" + }, + "is_buildtime": { + "type": "boolean" + }, "is_shared": { "type": "boolean" }, @@ -7989,6 +9555,9 @@ "is_swarm_worker": { "type": "boolean" }, + "is_terminal_enabled": { + "type": "boolean" + }, "is_usable": { "type": "boolean" }, @@ -8265,6 +9834,40 @@ } } } + }, + "422": { + "description": "Validation error.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Validation error." + }, + "errors": { + "type": "object", + "example": { + "name": [ + "The name field is required." + ], + "api_url": [ + "The api url field is required.", + "The api url format is invalid." + ] + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "type": "object" + } + } + } } }, "securitySchemes": { @@ -8288,6 +9891,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "GitHub Apps", + "description": "GitHub Apps" + }, { "name": "Projects", "description": "Projects" diff --git a/openapi.yaml b/openapi.yaml index c5113d9f7..b7df65567 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -259,6 +259,12 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -273,6 +279,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -509,6 +525,12 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -523,6 +545,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -759,6 +791,12 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -773,6 +811,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -956,6 +1004,12 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -970,6 +1024,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1144,6 +1208,12 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -1158,6 +1228,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1212,6 +1292,12 @@ paths: type: boolean nullable: true description: 'Use build server.' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -1226,6 +1312,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1539,6 +1635,12 @@ paths: type: boolean nullable: true description: 'Use build server.' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '200': @@ -1555,6 +1657,16 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1666,9 +1778,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -1731,9 +1840,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -1789,7 +1895,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } type: object responses: '201': @@ -1991,6 +2097,39 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/backups': + get: + tags: + - Databases + summary: Get + description: 'Get backups details by database UUID.' + operationId: get-database-backups-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'Get all backups for a database' + content: + application/json: + schema: + type: string + example: 'Content is very complex. Will be implemented later.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/databases/{uuid}': get: tags: @@ -2238,6 +2377,143 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/backups/{scheduled_backup_uuid}': + delete: + tags: + - Databases + summary: 'Delete backup configuration' + description: 'Deletes a backup configuration and all its executions.' + operationId: delete-backup-configuration-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database' + required: true + schema: + type: string + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration to delete' + required: true + schema: + type: string + format: uuid + - + name: delete_s3 + in: query + description: 'Whether to delete all backup files from S3' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Backup configuration deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Backup configuration and all executions deleted.' } + type: object + '404': + description: 'Backup configuration not found.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Backup configuration not found.' } + type: object + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: Update + description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID' + operationId: update-database-backup + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration.' + required: true + schema: + type: string + format: uuid + requestBody: + description: 'Database backup configuration data' + required: true + content: + application/json: + schema: + properties: + save_s3: + type: boolean + description: 'Whether data is saved in s3 or not' + s3_storage_uuid: + type: string + description: 'S3 storage UUID' + backup_now: + type: boolean + description: 'Whether to take a backup now or not' + enabled: + type: boolean + description: 'Whether the backup is enabled or not' + databases_to_backup: + type: string + description: 'Comma separated list of databases to backup' + dump_all: + type: boolean + description: 'Whether all databases are dumped or not' + frequency: + type: string + description: 'Frequency of the backup' + database_backup_retention_amount_locally: + type: integer + description: 'Retention amount of the backup locally' + database_backup_retention_days_locally: + type: integer + description: 'Retention days of the backup locally' + database_backup_retention_max_storage_locally: + type: integer + description: 'Max storage of the backup locally' + database_backup_retention_amount_s3: + type: integer + description: 'Retention amount of the backup in s3' + database_backup_retention_days_s3: + type: integer + description: 'Retention days of the backup in s3' + database_backup_retention_max_storage_s3: + type: integer + description: 'Max storage of the backup in S3' + type: object + responses: + '200': + description: 'Database backup configuration updated' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2340,6 +2616,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2430,6 +2708,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2517,6 +2797,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2607,6 +2889,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2697,6 +2981,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2796,6 +3082,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2895,6 +3183,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -2985,6 +3275,104 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}': + delete: + tags: + - Databases + summary: 'Delete backup execution' + description: 'Deletes a specific backup execution.' + operationId: delete-backup-execution-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database' + required: true + schema: + type: string + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration' + required: true + schema: + type: string + format: uuid + - + name: execution_uuid + in: path + description: 'UUID of the backup execution to delete' + required: true + schema: + type: string + format: uuid + - + name: delete_s3 + in: query + description: 'Whether to delete the backup from S3' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Backup execution deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Backup execution deleted.' } + type: object + '404': + description: 'Backup execution not found.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Backup execution not found.' } + type: object + security: + - + bearerAuth: [] + '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions': + get: + tags: + - Databases + summary: 'List backup executions' + description: 'Get all executions for a specific backup configuration.' + operationId: list-backup-executions + parameters: + - + name: uuid + in: path + description: 'UUID of the database' + required: true + schema: + type: string + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'List of backup executions' + content: + application/json: + schema: + properties: + executions: { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { type: string }, status: { type: string } }, type: object } } + type: object + '404': + description: 'Backup configuration not found.' security: - bearerAuth: [] @@ -3242,6 +3630,300 @@ paths: security: - bearerAuth: [] + /github-apps: + post: + tags: + - 'GitHub Apps' + summary: 'Create GitHub App' + description: 'Create a new GitHub app.' + operationId: create-github-app + requestBody: + description: 'GitHub app creation payload.' + required: true + content: + application/json: + schema: + required: + - name + - api_url + - html_url + - app_id + - installation_id + - client_id + - client_secret + - private_key_uuid + properties: + name: + type: string + description: 'Name of the GitHub app.' + organization: + type: string + nullable: true + description: 'Organization to associate the app with.' + api_url: + type: string + description: 'API URL for the GitHub app (e.g., https://api.github.com).' + html_url: + type: string + description: 'HTML URL for the GitHub app (e.g., https://github.com).' + custom_user: + type: string + description: 'Custom user for SSH access (default: git).' + custom_port: + type: integer + description: 'Custom port for SSH access (default: 22).' + app_id: + type: integer + description: 'GitHub App ID from GitHub.' + installation_id: + type: integer + description: 'GitHub Installation ID.' + client_id: + type: string + description: 'GitHub OAuth App Client ID.' + client_secret: + type: string + description: 'GitHub OAuth App Client Secret.' + webhook_secret: + type: string + description: 'Webhook secret for GitHub webhooks.' + private_key_uuid: + type: string + description: 'UUID of an existing private key for GitHub App authentication.' + is_system_wide: + type: boolean + description: 'Is this app system-wide (cloud only).' + type: object + responses: + '201': + description: 'GitHub app created successfully.' + content: + application/json: + schema: + properties: + id: { type: integer } + uuid: { type: string } + name: { type: string } + organization: { type: string, nullable: true } + api_url: { type: string } + html_url: { type: string } + custom_user: { type: string } + custom_port: { type: integer } + app_id: { type: integer } + installation_id: { type: integer } + client_id: { type: string } + private_key_id: { type: integer } + is_system_wide: { type: boolean } + team_id: { type: integer } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/github-apps/{github_app_id}/repositories': + get: + tags: + - 'GitHub Apps' + summary: 'Load Repositories for a GitHub App' + description: 'Fetch repositories from GitHub for a given GitHub app.' + operationId: load-repositories + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + responses: + '200': + description: 'Repositories loaded successfully.' + content: + application/json: + schema: + properties: + repositories: { type: array, items: { type: object } } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches': + get: + tags: + - 'GitHub Apps' + summary: 'Load Branches for a GitHub Repository' + description: 'Fetch branches from GitHub for a given repository.' + operationId: load-branches + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + - + name: owner + in: path + description: 'Repository owner' + required: true + schema: + type: string + - + name: repo + in: path + description: 'Repository name' + required: true + schema: + type: string + responses: + '200': + description: 'Branches loaded successfully.' + content: + application/json: + schema: + properties: + branches: { type: array, items: { type: object } } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/github-apps/{github_app_id}': + delete: + tags: + - 'GitHub Apps' + summary: 'Delete GitHub App' + description: "Delete a GitHub app if it's not being used by any applications." + operationId: deleteGithubApp + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + responses: + '200': + description: 'GitHub app deleted successfully' + content: + application/json: + schema: + properties: + message: { type: string, example: 'GitHub app deleted successfully' } + type: object + '401': + description: Unauthorized + '404': + description: 'GitHub app not found' + '409': + description: 'Conflict - GitHub app is in use' + content: + application/json: + schema: + properties: + message: { type: string, example: 'This GitHub app is being used by 5 application(s). Please delete all applications first.' } + type: object + security: + - + bearerAuth: [] + patch: + tags: + - 'GitHub Apps' + summary: 'Update GitHub App' + description: 'Update an existing GitHub app.' + operationId: updateGithubApp + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'GitHub App name' + organization: + type: string + nullable: true + description: 'GitHub organization' + api_url: + type: string + description: 'GitHub API URL' + html_url: + type: string + description: 'GitHub HTML URL' + custom_user: + type: string + description: 'Custom user for SSH' + custom_port: + type: integer + description: 'Custom port for SSH' + app_id: + type: integer + description: 'GitHub App ID' + installation_id: + type: integer + description: 'GitHub Installation ID' + client_id: + type: string + description: 'GitHub Client ID' + client_secret: + type: string + description: 'GitHub Client Secret' + webhook_secret: + type: string + description: 'GitHub Webhook Secret' + private_key_uuid: + type: string + description: 'Private key UUID' + is_system_wide: + type: boolean + description: 'Is system wide (non-cloud instances only)' + type: object + responses: + '200': + description: 'GitHub app updated successfully' + content: + application/json: + schema: + properties: + message: { type: string, example: 'GitHub app updated successfully' } + data: { type: object, description: 'Updated GitHub app data' } + type: object + '401': + description: Unauthorized + '404': + description: 'GitHub app not found' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /version: get: summary: Version @@ -3251,7 +3933,7 @@ paths: '200': description: 'Returns the version of the application' content: - application/json: + text/html: schema: type: string example: v4.0.0 @@ -3329,7 +4011,7 @@ paths: '200': description: 'Healthcheck endpoint.' content: - application/json: + text/html: schema: type: string example: OK @@ -3395,6 +4077,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3459,6 +4143,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3508,6 +4194,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3546,6 +4234,132 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/projects/{uuid}/environments': + get: + tags: + - Projects + summary: 'List Environments' + description: 'List all environments in a project.' + operationId: get-environments + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + responses: + '200': + description: 'List of environments' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Environment' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + description: 'Project not found.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + post: + tags: + - Projects + summary: 'Create Environment' + description: 'Create environment in project.' + operationId: create-environment + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + requestBody: + description: 'Environment created.' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the environment.' + type: object + responses: + '201': + description: 'Environment created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: env123, description: 'The UUID of the environment.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + description: 'Project not found.' + '409': + description: 'Environment with this name already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/projects/{uuid}/environments/{environment_name_or_uuid}': + delete: + tags: + - Projects + summary: 'Delete Environment' + description: 'Delete environment by name or UUID. Environment must be empty.' + operationId: delete-environment + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Environment deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Environment deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + description: 'Environment has resources, so it cannot be deleted.' + '404': + description: 'Project or environment not found.' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3629,6 +4443,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3667,6 +4483,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3830,6 +4648,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3894,6 +4714,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -3960,6 +4782,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4057,6 +4881,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4149,6 +4975,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4268,12 +5096,6 @@ paths: content: application/json: schema: - required: - - server_uuid - - project_uuid - - environment_name - - environment_uuid - - docker_compose_raw properties: name: type: string @@ -4323,6 +5145,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4391,9 +5215,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -4419,6 +5240,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4456,9 +5279,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -4484,6 +5304,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4514,7 +5336,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } type: object responses: '201': @@ -4531,6 +5353,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' security: - bearerAuth: [] @@ -4660,6 +5484,13 @@ paths: schema: type: string format: uuid + - + name: latest + in: query + description: 'Pull latest images.' + schema: + type: boolean + default: false responses: '200': description: 'Restart service.' @@ -5186,14 +6017,16 @@ components: type: string resourceable_id: type: integer - is_build_time: - type: boolean is_literal: type: boolean is_multiline: type: boolean is_preview: type: boolean + is_runtime: + type: boolean + is_buildtime: + type: boolean is_shared: type: boolean is_shown_once: @@ -5349,6 +6182,8 @@ components: type: boolean is_swarm_worker: type: boolean + is_terminal_enabled: + type: boolean is_usable: type: boolean logdrain_axiom_api_key: @@ -5543,6 +6378,24 @@ components: type: string example: 'Resource not found.' type: object + '422': + description: 'Validation error.' + content: + application/json: + schema: + properties: + message: + type: string + example: 'Validation error.' + errors: + type: object + example: + name: ['The name field is required.'] + api_url: ['The api url field is required.', 'The api url format is invalid.'] + additionalProperties: + type: array + items: { type: string } + type: object securitySchemes: bearerAuth: type: http @@ -5558,6 +6411,9 @@ tags: - name: Deployments description: Deployments + - + name: 'GitHub Apps' + description: 'GitHub Apps' - name: Projects description: Projects diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index fa30677ad..b90f126a2 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -61,7 +61,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.8' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index e19ec961f..09ce3ead3 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -103,7 +103,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.0' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/install.sh b/other/nightly/install.sh index f0fcdfb4e..bcd37e71f 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,6 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -VERSION="20" DOCKER_VERSION="27.0" # TODO: Ask for a user CURRENT_USER=$USER @@ -32,7 +31,7 @@ fi echo -e "Welcome to Coolify Installer!" echo -e "This script will install everything for you. Sit back and relax." -echo -e "Source code: https://github.com/coollabsio/coolify/blob/main/scripts/install.sh\n" +echo -e "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" # Predefined root user ROOT_USERNAME=${ROOT_USERNAME:-} @@ -253,6 +252,11 @@ if [ "$OS_TYPE" = "endeavouros" ]; then OS_TYPE="arch" fi +# Check if the OS is Cachy OS, if so, change it to arch +if [ "$OS_TYPE" = "cachyos" ]; then + OS_TYPE="arch" +fi + # Check if the OS is Asahi Linux, if so, change it to fedora if [ "$OS_TYPE" = "fedora-asahi-remix" ]; then OS_TYPE="fedora" @@ -438,7 +442,7 @@ fi if [ -x "$(command -v snap)" ]; then SNAP_DOCKER_INSTALLED=$(snap list docker >/dev/null 2>&1 && echo "true" || echo "false") if [ "$SNAP_DOCKER_INSTALLED" = "true" ]; then - echo " - Docker is installed via snap." + echo "Docker is installed via snap." echo " Please note that Coolify does not support Docker installed via snap." echo " Please remove Docker with snap (snap remove docker) and reexecute this script." exit 1 @@ -446,16 +450,46 @@ if [ -x "$(command -v snap)" ]; then fi install_docker() { + set +e curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 if ! [ -x "$(command -v docker)" ]; then - echo " - Docker installation failed." - echo " Maybe your OS is not supported?" - echo " - Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi fi + set -e +} + +install_docker_manually() { + case "$OS_TYPE" in + "ubuntu" | "debian" | "raspbian") + apt-get update + apt-get install -y ca-certificates curl + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/$OS_TYPE/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + + # Add the repository to Apt sources + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$OS_TYPE \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | + tee /etc/apt/sources.list.d/docker.list + apt-get update + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + ;; + *) + exit 1 + ;; + esac + if ! [ -x "$(command -v docker)" ]; then + echo "Docker installation failed." + echo " Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + else + echo "Docker installed successfully." + fi } echo -e "3. Check Docker Installation. " if ! [ -x "$(command -v docker)" ]; then @@ -522,34 +556,18 @@ if ! [ -x "$(command -v docker)" ]; then systemctl enable docker >/dev/null 2>&1 ;; "ubuntu" | "debian" | "raspbian") - if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then - echo " - Installing Docker for Ubuntu 24.10..." - apt-get update >/dev/null - apt-get install -y ca-certificates curl >/dev/null - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - - # Add the repository to Apt sources - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | - tee /etc/apt/sources.list.d/docker.list >/dev/null - apt-get update >/dev/null - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null - - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker installation failed." - echo " Please visit https://docs.docker.com/engine/install/ubuntu/ and install Docker manually to continue." - exit 1 - fi - echo " - Docker installed successfully for Ubuntu 24.10." - else - install_docker + install_docker + if ! [ -x "$(command -v docker)" ]; then + echo " - Automated Docker installation failed. Trying manual installation." + install_docker_manually fi ;; *) install_docker + if ! [ -x "$(command -v docker)" ]; then + echo " - Automated Docker installation failed. Trying manual installation." + install_docker_manually + fi ;; esac echo " - Docker installed successfully." @@ -692,84 +710,80 @@ curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.p curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh -echo -e "6. Make backup of .env to .env-$DATE" +echo -e "6. Setting up environment variable file" -# Copy .env.example if .env does not exist -if [ -f $ENV_FILE ]; then - cp $ENV_FILE $ENV_FILE-$DATE +if [ -f "$ENV_FILE" ]; then + # If .env exists, create backup + echo " - Creating backup of existing .env file to .env-$DATE" + cp "$ENV_FILE" "$ENV_FILE-$DATE" + # Merge .env.production values into .env + echo " - Merging .env.production values into .env" + awk -F '=' '!seen[$1]++' "$ENV_FILE" "/data/coolify/source/.env.production" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + echo " - .env file merged successfully" else - echo " - File does not exist: $ENV_FILE" - echo " - Copying .env.production to .env-$DATE" - cp /data/coolify/source/.env.production $ENV_FILE-$DATE - # Generate a secure APP_ID and APP_KEY - sed -i "s|^APP_ID=.*|APP_ID=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" - sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate a secure Postgres DB username and password - # Causes issues: database "random-user" does not exist - # sed -i "s|^DB_USERNAME=.*|DB_USERNAME=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" - sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate a secure Redis password - sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate secure Pusher credentials - sed -i "s|^PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" - sed -i "s|^PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" - sed -i "s|^PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" + # If no .env exists, copy .env.production to .env + echo " - No .env file found, copying .env.production to .env" + cp "/data/coolify/source/.env.production" "$ENV_FILE" fi +echo -e "7. Checking and updating environment variables if necessary..." + +update_env_var() { + local key="$1" + local value="$2" + + # If variable "key=" exists but has no value, update the value of the existing line + if grep -q "^${key}=$" "$ENV_FILE"; then + sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + echo " - Updated value of ${key} as the current value was empty" + # If variable "key=" doesn't exist, append it to the file with value + elif ! grep -q "^${key}=" "$ENV_FILE"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + echo " - Added ${key} and it's value as the variable was missing" + fi +} + +update_env_var "APP_ID" "$(openssl rand -hex 16)" +update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)" +# update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist +update_env_var "DB_PASSWORD" "$(openssl rand -base64 32)" +update_env_var "REDIS_PASSWORD" "$(openssl rand -base64 32)" +update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" + # Add default root user credentials from environment variables if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then - if grep -q "^ROOT_USERNAME=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USERNAME=.*|ROOT_USERNAME=$ROOT_USERNAME|" "$ENV_FILE-$DATE" - fi - if grep -q "^ROOT_USER_EMAIL=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USER_EMAIL=.*|ROOT_USER_EMAIL=$ROOT_USER_EMAIL|" "$ENV_FILE-$DATE" - fi - if grep -q "^ROOT_USER_PASSWORD=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USER_PASSWORD=.*|ROOT_USER_PASSWORD=$ROOT_USER_PASSWORD|" "$ENV_FILE-$DATE" - fi + echo " - Setting predefined root user credentials from environment" + update_env_var "ROOT_USERNAME" "$ROOT_USERNAME" + update_env_var "ROOT_USER_EMAIL" "$ROOT_USER_EMAIL" + update_env_var "ROOT_USER_PASSWORD" "$ROOT_USER_PASSWORD" fi -# Add registry URL to .env file if [ -n "${REGISTRY_URL+x}" ]; then # Only update if REGISTRY_URL was explicitly provided - if grep -q "^REGISTRY_URL=" "$ENV_FILE-$DATE"; then - sed -i "s|^REGISTRY_URL=.*|REGISTRY_URL=$REGISTRY_URL|" "$ENV_FILE-$DATE" - else - echo "REGISTRY_URL=$REGISTRY_URL" >>"$ENV_FILE-$DATE" - fi + update_env_var "REGISTRY_URL" "$REGISTRY_URL" fi -# Merge .env and .env.production. New values will be added to .env -echo -e "7. Propagating .env with new values - if necessary." -awk -F '=' '!seen[$1]++' "$ENV_FILE-$DATE" /data/coolify/source/.env.production >$ENV_FILE - if [ "$AUTOUPDATE" = "false" ]; then - if ! grep -q "AUTOUPDATE=" /data/coolify/source/.env; then - echo "AUTOUPDATE=false" >>/data/coolify/source/.env - else - sed -i "s|AUTOUPDATE=.*|AUTOUPDATE=false|g" /data/coolify/source/.env + update_env_var "AUTOUPDATE" "false" +fi + +if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then + update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE" +else + # Add with default value if missing + if ! grep -q "^DOCKER_ADDRESS_POOL_BASE=" "$ENV_FILE"; then + update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE" fi fi -# Save Docker address pool configuration to .env file -if ! grep -q "DOCKER_ADDRESS_POOL_BASE=" /data/coolify/source/.env; then - echo "DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE" >>/data/coolify/source/.env +if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then + update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" else - # Only update if explicitly provided - if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then - sed -i "s|DOCKER_ADDRESS_POOL_BASE=.*|DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE|g" /data/coolify/source/.env - fi -fi - -if ! grep -q "DOCKER_ADDRESS_POOL_SIZE=" /data/coolify/source/.env; then - echo "DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE" >>/data/coolify/source/.env -else - # Only update if explicitly provided - if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then - sed -i "s|DOCKER_ADDRESS_POOL_SIZE=.*|DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE|g" /data/coolify/source/.env + # Add with default value if missing + if ! grep -q "^DOCKER_ADDRESS_POOL_SIZE=" "$ENV_FILE"; then + update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" fi fi @@ -787,6 +801,8 @@ set -e if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then echo " - Generating SSH key." + test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal + test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal sed -i "/coolify/d" ~/.ssh/authorized_keys @@ -803,14 +819,13 @@ echo -e " - Please wait." getAJoke if [[ $- == *x* ]]; then - bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" + bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" else - bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" + bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" fi echo " - Coolify installed successfully." -rm -f $ENV_FILE-$DATE -echo " - Waiting for 20 seconds for Coolify (database migrations) to be ready." +echo " - Waiting 20 seconds for Coolify database migrations to complete." getAJoke sleep 20 @@ -828,7 +843,7 @@ IPV6_PUBLIC_IP=$(curl -6s https://ifconfig.io || true) echo -e "\nYour instance is ready to use!\n" if [ -n "$IPV4_PUBLIC_IP" ]; then - echo -e "You can access Coolify through your Public IPV4: http://$(curl -4s https://ifconfig.io):8000" + echo -e "You can access Coolify through your Public IPV4: http://$IPV4_PUBLIC_IP:8000" fi if [ -n "$IPV6_PUBLIC_IP" ]; then echo -e "You can access Coolify through your Public IPv6: http://[$IPV6_PUBLIC_IP]:8000" @@ -847,5 +862,5 @@ if [ -n "$PRIVATE_IPS" ]; then fi done fi + echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n" -cp /data/coolify/source/.env /data/coolify/source/.env.backup diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 0b031ca75..14eede4ee 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -1,11 +1,12 @@ #!/bin/bash ## Do not modify this file. You will lose the ability to autoupdate! -VERSION="15" CDN="https://cdn.coollabs.io/coolify-nightly" LATEST_IMAGE=${1:-latest} LATEST_HELPER_VERSION=${2:-latest} REGISTRY_URL=${3:-ghcr.io} +SKIP_BACKUP=${4:-false} +ENV_FILE="/data/coolify/source/.env" DATE=$(date +%Y-%m-%d-%H-%M-%S) LOGFILE="/data/coolify/source/upgrade-${DATE}.log" @@ -14,20 +15,39 @@ curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production -# Merge .env and .env.production. New values will be added to .env -awk -F '=' '!seen[$1]++' /data/coolify/source/.env /data/coolify/source/.env.production >/data/coolify/source/.env.tmp && mv /data/coolify/source/.env.tmp /data/coolify/source/.env -# Check if PUSHER_APP_ID or PUSHER_APP_KEY or PUSHER_APP_SECRET is empty in /data/coolify/source/.env -if grep -q "PUSHER_APP_ID=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|g" /data/coolify/source/.env +# Backup existing .env file before making any changes +if [ "$SKIP_BACKUP" != "true" ]; then + if [ -f "$ENV_FILE" ]; then + echo "Creating backup of existing .env file to .env-$DATE" >>"$LOGFILE" + cp "$ENV_FILE" "$ENV_FILE-$DATE" + else + echo "No existing .env file found to backup" >>"$LOGFILE" + fi fi -if grep -q "PUSHER_APP_KEY=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|g" /data/coolify/source/.env -fi +echo "Merging .env.production values into .env" >>"$LOGFILE" +awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" +echo ".env file merged successfully" >>"$LOGFILE" -if grep -q "PUSHER_APP_SECRET=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|g" /data/coolify/source/.env -fi +update_env_var() { + local key="$1" + local value="$2" + + # If variable "key=" exists but has no value, update the value of the existing line + if grep -q "^${key}=$" "$ENV_FILE"; then + sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + echo " - Updated value of ${key} as the current value was empty" >>"$LOGFILE" + # If variable "key=" doesn't exist, append it to the file with value + elif ! grep -q "^${key}=" "$ENV_FILE"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + echo " - Added ${key} with default value as the variable was missing" >>"$LOGFILE" + fi +} + +echo "Checking and updating environment variables if necessary..." >>"$LOGFILE" +update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" # Make sure coolify network exists # It is created when starting Coolify with docker compose @@ -37,11 +57,16 @@ if ! docker network inspect coolify >/dev/null 2>&1; then docker network create --attachable coolify 2>/dev/null fi fi -# docker network create --attachable --driver=overlay coolify-overlay 2>/dev/null + +# Check if Docker config file exists +DOCKER_CONFIG_MOUNT="" +if [ -f /root/.docker/config.json ]; then + DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json" +fi if [ -f /data/coolify/source/docker-compose.custom.yml ]; then - echo "docker-compose.custom.yml detected." >>$LOGFILE - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>$LOGFILE 2>&1 + echo "docker-compose.custom.yml detected." >>"$LOGFILE" + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>$LOGFILE 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 fi diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 6b41be267..2e5cc5e84 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.419" + "version": "4.0.0-beta.435" }, "nightly": { - "version": "4.0.0-beta.420" + "version": "4.0.0-beta.436" }, "helper": { - "version": "1.0.8" + "version": "1.0.11" }, "realtime": { - "version": "1.0.8" + "version": "1.0.10" }, "sentinel": { - "version": "0.0.15" + "version": "0.0.16" } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7996a3653..fa5ac7aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,22 +8,22 @@ "dependencies": { "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.5.0", - "ioredis": "5.6.0" + "@xterm/addon-fit": "0.10.0", + "@xterm/xterm": "5.5.0", + "ioredis": "5.6.1" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.4", - "@vitejs/plugin-vue": "5.2.3", - "axios": "1.8.4", - "laravel-echo": "2.0.2", - "laravel-vite-plugin": "^1.2.0", - "postcss": "8.5.3", + "@tailwindcss/postcss": "4.1.10", + "@vitejs/plugin-vue": "5.2.4", + "axios": "1.9.0", + "laravel-echo": "2.1.5", + "laravel-vite-plugin": "1.3.0", + "postcss": "8.5.5", "pusher-js": "8.4.0", - "tailwind-scrollbar": "^4.0.2", - "tailwindcss": "^4.1.4", - "vite": "^6.2.6", - "vue": "3.5.13" + "tailwind-scrollbar": "4.0.2", + "tailwindcss": "4.1.10", + "vite": "6.3.6", + "vue": "3.5.16" } }, "node_modules/@alloc/quick-lru": { @@ -74,13 +74,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -90,9 +90,9 @@ } }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -104,9 +104,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "cpu": [ "ppc64" ], @@ -121,9 +121,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "cpu": [ "arm" ], @@ -138,9 +138,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "cpu": [ "arm64" ], @@ -155,9 +155,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "cpu": [ "x64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], @@ -189,9 +189,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", "cpu": [ "x64" ], @@ -206,9 +206,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "cpu": [ "x64" ], @@ -240,9 +240,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "cpu": [ "arm" ], @@ -257,9 +257,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "cpu": [ "arm64" ], @@ -274,9 +274,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "cpu": [ "ia32" ], @@ -291,9 +291,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "cpu": [ "loong64" ], @@ -308,9 +308,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "cpu": [ "mips64el" ], @@ -325,9 +325,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "cpu": [ "ppc64" ], @@ -342,9 +342,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "cpu": [ "riscv64" ], @@ -359,9 +359,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "cpu": [ "s390x" ], @@ -376,9 +376,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", "cpu": [ "x64" ], @@ -393,9 +393,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "cpu": [ "arm64" ], @@ -410,9 +410,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "cpu": [ "x64" ], @@ -427,9 +427,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "cpu": [ "arm64" ], @@ -444,9 +444,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "cpu": [ "x64" ], @@ -460,10 +460,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], @@ -478,9 +495,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "cpu": [ "arm64" ], @@ -495,9 +512,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], @@ -512,9 +529,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", "cpu": [ "x64" ], @@ -529,9 +546,9 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", "license": "MIT" }, "node_modules/@isaacs/fs-minipass": { @@ -548,18 +565,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -572,27 +585,17 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -601,9 +604,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", - "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", "cpu": [ "arm" ], @@ -615,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", - "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", "cpu": [ "arm64" ], @@ -629,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", - "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", "cpu": [ "arm64" ], @@ -643,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", - "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", "cpu": [ "x64" ], @@ -657,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", - "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", "cpu": [ "arm64" ], @@ -671,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", - "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", "cpu": [ "x64" ], @@ -685,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", - "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", "cpu": [ "arm" ], @@ -699,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", - "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", "cpu": [ "arm" ], @@ -713,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", - "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", "cpu": [ "arm64" ], @@ -727,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", - "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", "cpu": [ "arm64" ], @@ -740,10 +743,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", - "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", "cpu": [ "loong64" ], @@ -754,10 +757,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", - "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", "cpu": [ "ppc64" ], @@ -769,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", - "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", "cpu": [ "riscv64" ], @@ -783,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", - "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", "cpu": [ "riscv64" ], @@ -797,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", - "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", "cpu": [ "s390x" ], @@ -811,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", - "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", "cpu": [ "x64" ], @@ -825,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", - "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", "cpu": [ "x64" ], @@ -838,10 +841,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", - "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", "cpu": [ "arm64" ], @@ -853,9 +870,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", - "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", "cpu": [ "ia32" ], @@ -866,10 +883,10 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", - "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", "cpu": [ "x64" ], @@ -880,6 +897,28 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -893,25 +932,25 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.6.tgz", - "integrity": "sha512-ed6zQbgmKsjsVvodAS1q1Ld2BolEuxJOSyyNc+vhkjdmfNUDCmQnlXBfQkHrlzNmslxHsQU/bFmzcEbv4xXsLg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz", + "integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "lightningcss": "1.29.2", + "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.6" + "tailwindcss": "4.1.10" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.6.tgz", - "integrity": "sha512-0bpEBQiGx+227fW4G0fLQ8vuvyy5rsB1YIYNapTq3aRsJ9taF3f5cCaovDjN5pUGKKzcpMrZst/mhNaKAPOHOA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz", + "integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -923,24 +962,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.6", - "@tailwindcss/oxide-darwin-arm64": "4.1.6", - "@tailwindcss/oxide-darwin-x64": "4.1.6", - "@tailwindcss/oxide-freebsd-x64": "4.1.6", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.6", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.6", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.6", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.6", - "@tailwindcss/oxide-linux-x64-musl": "4.1.6", - "@tailwindcss/oxide-wasm32-wasi": "4.1.6", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.6", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.6" + "@tailwindcss/oxide-android-arm64": "4.1.10", + "@tailwindcss/oxide-darwin-arm64": "4.1.10", + "@tailwindcss/oxide-darwin-x64": "4.1.10", + "@tailwindcss/oxide-freebsd-x64": "4.1.10", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", + "@tailwindcss/oxide-linux-x64-musl": "4.1.10", + "@tailwindcss/oxide-wasm32-wasi": "4.1.10", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.6.tgz", - "integrity": "sha512-VHwwPiwXtdIvOvqT/0/FLH/pizTVu78FOnI9jQo64kSAikFSZT7K4pjyzoDpSMaveJTGyAKvDjuhxJxKfmvjiQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", + "integrity": "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==", "cpu": [ "arm64" ], @@ -955,9 +994,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.6.tgz", - "integrity": "sha512-weINOCcqv1HVBIGptNrk7c6lWgSFFiQMcCpKM4tnVi5x8OY2v1FrV76jwLukfT6pL1hyajc06tyVmZFYXoxvhQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", + "integrity": "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==", "cpu": [ "arm64" ], @@ -972,9 +1011,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.6.tgz", - "integrity": "sha512-3FzekhHG0ww1zQjQ1lPoq0wPrAIVXAbUkWdWM8u5BnYFZgb9ja5ejBqyTgjpo5mfy0hFOoMnMuVDI+7CXhXZaQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", + "integrity": "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==", "cpu": [ "x64" ], @@ -989,9 +1028,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.6.tgz", - "integrity": "sha512-4m5F5lpkBZhVQJq53oe5XgJ+aFYWdrgkMwViHjRsES3KEu2m1udR21B1I77RUqie0ZYNscFzY1v9aDssMBZ/1w==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", + "integrity": "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==", "cpu": [ "x64" ], @@ -1006,9 +1045,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.6.tgz", - "integrity": "sha512-qU0rHnA9P/ZoaDKouU1oGPxPWzDKtIfX7eOGi5jOWJKdxieUJdVV+CxWZOpDWlYTd4N3sFQvcnVLJWJ1cLP5TA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", + "integrity": "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==", "cpu": [ "arm" ], @@ -1023,9 +1062,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.6.tgz", - "integrity": "sha512-jXy3TSTrbfgyd3UxPQeXC3wm8DAgmigzar99Km9Sf6L2OFfn/k+u3VqmpgHQw5QNfCpPe43em6Q7V76Wx7ogIQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", + "integrity": "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==", "cpu": [ "arm64" ], @@ -1040,9 +1079,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.6.tgz", - "integrity": "sha512-8kjivE5xW0qAQ9HX9reVFmZj3t+VmljDLVRJpVBEoTR+3bKMnvC7iLcoSGNIUJGOZy1mLVq7x/gerVg0T+IsYw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", + "integrity": "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==", "cpu": [ "arm64" ], @@ -1057,9 +1096,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.6.tgz", - "integrity": "sha512-A4spQhwnWVpjWDLXnOW9PSinO2PTKJQNRmL/aIl2U/O+RARls8doDfs6R41+DAXK0ccacvRyDpR46aVQJJCoCg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", + "integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==", "cpu": [ "x64" ], @@ -1074,9 +1113,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.6.tgz", - "integrity": "sha512-YRee+6ZqdzgiQAHVSLfl3RYmqeeaWVCk796MhXhLQu2kJu2COHBkqlqsqKYx3p8Hmk5pGCQd2jTAoMWWFeyG2A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", + "integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==", "cpu": [ "x64" ], @@ -1091,9 +1130,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.6.tgz", - "integrity": "sha512-qAp4ooTYrBQ5pk5jgg54/U1rCJ/9FLYOkkQ/nTE+bVMseMfB6O7J8zb19YTpWuu4UdfRf5zzOrNKfl6T64MNrQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", + "integrity": "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1112,7 +1151,7 @@ "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.9", + "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, @@ -1121,9 +1160,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.6.tgz", - "integrity": "sha512-nqpDWk0Xr8ELO/nfRUDjk1pc9wDJ3ObeDdNMHLaymc4PJBWj11gdPCWZFKSK2AVKjJQC7J2EfmSmf47GN7OuLg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", + "integrity": "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==", "cpu": [ "arm64" ], @@ -1138,9 +1177,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.6.tgz", - "integrity": "sha512-5k9xF33xkfKpo9wCvYcegQ21VwIBU1/qEbYlVukfEIyQbEA47uK8AAwS7NVjNE3vHzcmxMYwd0l6L4pPjjm1rQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", + "integrity": "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==", "cpu": [ "x64" ], @@ -1155,17 +1194,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.6.tgz", - "integrity": "sha512-ELq+gDMBuRXPJlpE3PEen+1MhnHAQQrh2zF0dI1NXOlEWfr2qWf2CQdr5jl9yANv8RErQaQ2l6nIFO9OSCVq/g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.10.tgz", + "integrity": "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.6", - "@tailwindcss/oxide": "4.1.6", + "@tailwindcss/node": "4.1.10", + "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", - "tailwindcss": "4.1.6" + "tailwindcss": "4.1.10" } }, "node_modules/@tailwindcss/typography": { @@ -1184,9 +1223,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -1198,9 +1237,9 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz", - "integrity": "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==", + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", "dev": true, "license": "MIT", "engines": { @@ -1212,111 +1251,111 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", - "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.16.tgz", + "integrity": "sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.13", + "@babel/parser": "^7.27.2", + "@vue/shared": "3.5.16", "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", - "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz", + "integrity": "sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-core": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", - "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz", + "integrity": "sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/compiler-core": "3.5.13", - "@vue/compiler-dom": "3.5.13", - "@vue/compiler-ssr": "3.5.13", - "@vue/shared": "3.5.13", + "@babel/parser": "^7.27.2", + "@vue/compiler-core": "3.5.16", + "@vue/compiler-dom": "3.5.16", + "@vue/compiler-ssr": "3.5.16", + "@vue/shared": "3.5.16", "estree-walker": "^2.0.2", - "magic-string": "^0.30.11", - "postcss": "^8.4.48", - "source-map-js": "^1.2.0" + "magic-string": "^0.30.17", + "postcss": "^8.5.3", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", - "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz", + "integrity": "sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-dom": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/reactivity": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", - "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz", + "integrity": "sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/shared": "3.5.13" + "@vue/shared": "3.5.16" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", - "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.16.tgz", + "integrity": "sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/reactivity": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", - "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz", + "integrity": "sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.13", - "@vue/runtime-core": "3.5.13", - "@vue/shared": "3.5.13", + "@vue/reactivity": "3.5.16", + "@vue/runtime-core": "3.5.16", + "@vue/shared": "3.5.16", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", - "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.16.tgz", + "integrity": "sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-ssr": "3.5.16", + "@vue/shared": "3.5.16" }, "peerDependencies": { - "vue": "3.5.13" + "vue": "3.5.16" } }, "node_modules/@vue/shared": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", - "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.16.tgz", + "integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==", "dev": true, "license": "MIT" }, @@ -1343,9 +1382,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", "dev": true, "license": "MIT", "dependencies": { @@ -1430,9 +1469,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1466,9 +1505,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1490,10 +1529,55 @@ "node": ">= 0.4" } }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1567,9 +1651,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1580,31 +1664,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.4", - "@esbuild/android-arm": "0.25.4", - "@esbuild/android-arm64": "0.25.4", - "@esbuild/android-x64": "0.25.4", - "@esbuild/darwin-arm64": "0.25.4", - "@esbuild/darwin-x64": "0.25.4", - "@esbuild/freebsd-arm64": "0.25.4", - "@esbuild/freebsd-x64": "0.25.4", - "@esbuild/linux-arm": "0.25.4", - "@esbuild/linux-arm64": "0.25.4", - "@esbuild/linux-ia32": "0.25.4", - "@esbuild/linux-loong64": "0.25.4", - "@esbuild/linux-mips64el": "0.25.4", - "@esbuild/linux-ppc64": "0.25.4", - "@esbuild/linux-riscv64": "0.25.4", - "@esbuild/linux-s390x": "0.25.4", - "@esbuild/linux-x64": "0.25.4", - "@esbuild/netbsd-arm64": "0.25.4", - "@esbuild/netbsd-x64": "0.25.4", - "@esbuild/openbsd-arm64": "0.25.4", - "@esbuild/openbsd-x64": "0.25.4", - "@esbuild/sunos-x64": "0.25.4", - "@esbuild/win32-arm64": "0.25.4", - "@esbuild/win32-ia32": "0.25.4", - "@esbuild/win32-x64": "0.25.4" + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" } }, "node_modules/estree-walker": { @@ -1615,11 +1700,14 @@ "license": "MIT" }, "node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -1630,9 +1718,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true, "funding": [ { @@ -1651,15 +1739,16 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -1793,9 +1882,9 @@ } }, "node_modules/ioredis": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.0.tgz", - "integrity": "sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", @@ -1817,9 +1906,9 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -1827,19 +1916,23 @@ } }, "node_modules/laravel-echo": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.0.2.tgz", - "integrity": "sha512-Ciai6hA7r35MFqNRb8G034cvm9WiveSTFQQKRGJhWtZGbng7C8BBa5QvqDxk/Mw5GeJ+q19jrEwQhf7r1b1lcg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.1.5.tgz", + "integrity": "sha512-xIlV7AYjfIXv9KGiDa3qqc7JOEJUqNl+6Nx/I6bdxnSAMqnNZT5Nc1rwjOYfoYEI6030QkKF8BhPuU6Roakebw==", "dev": true, "license": "MIT", "engines": { "node": ">=20" + }, + "peerDependencies": { + "pusher-js": "*", + "socket.io-client": "*" } }, "node_modules/laravel-vite-plugin": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz", - "integrity": "sha512-R0pJ+IcTVeqEMoKz/B2Ij57QVq3sFTABiFmb06gAwFdivbOgsUtuhX6N2MGLEArajrS3U5JbberzwOe7uXHMHQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", + "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==", "dev": true, "license": "MIT", "dependencies": { @@ -1857,9 +1950,9 @@ } }, "node_modules/lightningcss": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", - "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1873,22 +1966,22 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.2", - "lightningcss-darwin-x64": "1.29.2", - "lightningcss-freebsd-x64": "1.29.2", - "lightningcss-linux-arm-gnueabihf": "1.29.2", - "lightningcss-linux-arm64-gnu": "1.29.2", - "lightningcss-linux-arm64-musl": "1.29.2", - "lightningcss-linux-x64-gnu": "1.29.2", - "lightningcss-linux-x64-musl": "1.29.2", - "lightningcss-win32-arm64-msvc": "1.29.2", - "lightningcss-win32-x64-msvc": "1.29.2" + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", - "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", "cpu": [ "arm64" ], @@ -1907,9 +2000,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", - "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", "cpu": [ "x64" ], @@ -1928,9 +2021,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", - "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", "cpu": [ "x64" ], @@ -1949,9 +2042,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", - "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", "cpu": [ "arm" ], @@ -1970,9 +2063,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", - "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", "cpu": [ "arm64" ], @@ -1991,9 +2084,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", - "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", "cpu": [ "arm64" ], @@ -2012,9 +2105,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", - "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", "cpu": [ "x64" ], @@ -2033,9 +2126,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", - "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", "cpu": [ "x64" ], @@ -2054,9 +2147,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", - "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", "cpu": [ "arm64" ], @@ -2075,9 +2168,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", - "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", "cpu": [ "x64" ], @@ -2126,13 +2219,13 @@ "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { @@ -2188,9 +2281,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -2200,22 +2293,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2249,9 +2326,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -2262,9 +2339,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.5", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", + "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", "dev": true, "funding": [ { @@ -2282,7 +2359,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2335,9 +2412,9 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "dev": true, "license": "MIT", "peer": true, @@ -2367,13 +2444,13 @@ } }, "node_modules/rollup": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", - "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -2383,29 +2460,101 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.2", - "@rollup/rollup-android-arm64": "4.40.2", - "@rollup/rollup-darwin-arm64": "4.40.2", - "@rollup/rollup-darwin-x64": "4.40.2", - "@rollup/rollup-freebsd-arm64": "4.40.2", - "@rollup/rollup-freebsd-x64": "4.40.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", - "@rollup/rollup-linux-arm-musleabihf": "4.40.2", - "@rollup/rollup-linux-arm64-gnu": "4.40.2", - "@rollup/rollup-linux-arm64-musl": "4.40.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", - "@rollup/rollup-linux-riscv64-gnu": "4.40.2", - "@rollup/rollup-linux-riscv64-musl": "4.40.2", - "@rollup/rollup-linux-s390x-gnu": "4.40.2", - "@rollup/rollup-linux-x64-gnu": "4.40.2", - "@rollup/rollup-linux-x64-musl": "4.40.2", - "@rollup/rollup-win32-arm64-msvc": "4.40.2", - "@rollup/rollup-win32-ia32-msvc": "4.40.2", - "@rollup/rollup-win32-x64-msvc": "4.40.2", + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2439,33 +2588,36 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.6.tgz", - "integrity": "sha512-j0cGLTreM6u4OWzBeLBpycK0WIh8w7kSwcUsQZoGLHZ7xDTdM69lN64AgoIEEwFi0tnhs4wSykUa5YWxAzgFYg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", + "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", "dev": true, "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -2473,14 +2625,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -2503,9 +2655,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", "dependencies": { @@ -2602,17 +2754,17 @@ } }, "node_modules/vue": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", - "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz", + "integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.13", - "@vue/compiler-sfc": "3.5.13", - "@vue/runtime-dom": "3.5.13", - "@vue/server-renderer": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-dom": "3.5.16", + "@vue/compiler-sfc": "3.5.16", + "@vue/runtime-dom": "3.5.16", + "@vue/server-renderer": "3.5.16", + "@vue/shared": "3.5.16" }, "peerDependencies": { "typescript": "*" @@ -2623,6 +2775,39 @@ } } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 7e1a85c70..e29c5e8e6 100644 --- a/package.json +++ b/package.json @@ -7,23 +7,23 @@ "build": "vite build" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.4", - "@vitejs/plugin-vue": "5.2.3", - "axios": "1.8.4", - "laravel-echo": "2.0.2", - "laravel-vite-plugin": "^1.2.0", - "postcss": "8.5.3", + "@tailwindcss/postcss": "4.1.10", + "@vitejs/plugin-vue": "5.2.4", + "axios": "1.9.0", + "laravel-echo": "2.1.5", + "laravel-vite-plugin": "1.3.0", + "postcss": "8.5.5", "pusher-js": "8.4.0", - "tailwind-scrollbar": "^4.0.2", - "tailwindcss": "^4.1.4", - "vite": "^6.2.6", - "vue": "3.5.13" + "tailwind-scrollbar": "4.0.2", + "tailwindcss": "4.1.10", + "vite": "6.3.6", + "vue": "3.5.16" }, "dependencies": { "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.5.0", - "ioredis": "5.6.0" + "@xterm/addon-fit": "0.10.0", + "@xterm/xterm": "5.5.0", + "ioredis": "5.6.1" } -} +} \ No newline at end of file diff --git a/public/coolify-logo-dev-transparent.png b/public/coolify-logo-dev-transparent.png index 9beeb9ba3..4e65e8b72 100644 Binary files a/public/coolify-logo-dev-transparent.png and b/public/coolify-logo-dev-transparent.png differ diff --git a/public/coolify-logo-dev-transparent.svg b/public/coolify-logo-dev-transparent.svg new file mode 100644 index 000000000..a4159154f --- /dev/null +++ b/public/coolify-logo-dev-transparent.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo-monochrome.png b/public/coolify-logo-monochrome.png new file mode 100644 index 000000000..48605e8fd Binary files /dev/null and b/public/coolify-logo-monochrome.png differ diff --git a/public/coolify-logo-monochrome.svg b/public/coolify-logo-monochrome.svg new file mode 100644 index 000000000..f60f33f97 --- /dev/null +++ b/public/coolify-logo-monochrome.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo-red.png b/public/coolify-logo-red.png new file mode 100644 index 000000000..b3f7d2b6c Binary files /dev/null and b/public/coolify-logo-red.png differ diff --git a/public/coolify-logo-red.svg b/public/coolify-logo-red.svg new file mode 100644 index 000000000..4cbfef43f --- /dev/null +++ b/public/coolify-logo-red.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo.svg b/public/coolify-logo.svg index 6f4f641f5..bff8f6b40 100644 --- a/public/coolify-logo.svg +++ b/public/coolify-logo.svg @@ -1,9 +1 @@ - - - - - - - - - +Coolify \ No newline at end of file diff --git a/public/coolify-transparent.png b/public/coolify-transparent.png index 96fc0db36..99a56acbe 100644 Binary files a/public/coolify-transparent.png and b/public/coolify-transparent.png differ diff --git a/public/ente-photos-icon-green.png b/public/ente-photos-icon-green.png new file mode 100644 index 000000000..b74aa472d Binary files /dev/null and b/public/ente-photos-icon-green.png differ diff --git a/public/heart.png b/public/heart.png new file mode 100644 index 000000000..c44a21301 Binary files /dev/null and b/public/heart.png differ diff --git a/public/js/purify.min.js b/public/js/purify.min.js new file mode 100644 index 000000000..73df78d60 --- /dev/null +++ b/public/js/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.6",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...M,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Ie=!1,Me=!0,ke=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):D({}),Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):D({}),qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Ie=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Me=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,M),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...I]),yt=w({},[...M,...k]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=""+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),Ue&&e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w!]/g,e.innerHTML)&&S(/<[/\w!]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Ie&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a),m=c;let f="value"===a?m:A(m);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),f=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),f="user-content-"+f),Ue&&S(/((--!?|])>)|<\/(style|title)/i,f)){At(a,e);continue}if(n.forceKeepAttr)continue;if(!n.keepAttr){At(a,e);continue}if(!Me&&S(/\/>/i,f)){At(a,e);continue}ke&&u([he,ge,Te],(e=>{f=y(f,e," ")}));const d=pt(e.nodeName);if(Ot(d,s,f)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(d,s)){case"TrustedHTML":f=le.createHTML(f);break;case"TrustedScriptURL":f=le.createScriptURL(f)}if(f!==m)try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),bt(e)?Et(e):p(o.removed)}catch(t){At(a,e)}}else At(a,e)}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re})); +//# sourceMappingURL=purify.min.js.map diff --git a/public/svgs/bluesky.svg b/public/svgs/bluesky.svg new file mode 100644 index 000000000..77ebea072 --- /dev/null +++ b/public/svgs/bluesky.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/svgs/cap.svg b/public/svgs/cap.svg new file mode 100644 index 000000000..83d26e15d --- /dev/null +++ b/public/svgs/cap.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/chroma.svg b/public/svgs/chroma.svg new file mode 100644 index 000000000..930288fbf --- /dev/null +++ b/public/svgs/chroma.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/drizzle.jpeg b/public/svgs/drizzle.jpeg new file mode 100644 index 000000000..d84ff854b Binary files /dev/null and b/public/svgs/drizzle.jpeg differ diff --git a/public/svgs/elasticsearch.svg b/public/svgs/elasticsearch.svg new file mode 100644 index 000000000..bfc5bfb6a --- /dev/null +++ b/public/svgs/elasticsearch.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/svgs/ente-photos.svg b/public/svgs/ente-photos.svg new file mode 100644 index 000000000..e6a469e91 --- /dev/null +++ b/public/svgs/ente-photos.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/svgs/ente.png b/public/svgs/ente.png new file mode 100644 index 000000000..f510a7bf7 Binary files /dev/null and b/public/svgs/ente.png differ diff --git a/public/svgs/excalidraw.svg b/public/svgs/excalidraw.svg new file mode 100644 index 000000000..ee996762e --- /dev/null +++ b/public/svgs/excalidraw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/svgs/github-runner.png b/public/svgs/github-runner.png new file mode 100644 index 000000000..fb5b5c1b7 Binary files /dev/null and b/public/svgs/github-runner.png differ diff --git a/public/svgs/gotify.png b/public/svgs/gotify.png new file mode 100644 index 000000000..5fe38d60a Binary files /dev/null and b/public/svgs/gotify.png differ diff --git a/public/svgs/gowa.svg b/public/svgs/gowa.svg new file mode 100644 index 000000000..1121b05bc --- /dev/null +++ b/public/svgs/gowa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/gramps-web.svg b/public/svgs/gramps-web.svg new file mode 100644 index 000000000..eeef33047 --- /dev/null +++ b/public/svgs/gramps-web.svg @@ -0,0 +1,154 @@ + +image/svg+xml diff --git a/public/svgs/grist.svg b/public/svgs/grist.svg new file mode 100644 index 000000000..82975b768 --- /dev/null +++ b/public/svgs/grist.svg @@ -0,0 +1,18 @@ + + + + grist-logo-icon-transparent + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/hetzner.svg b/public/svgs/hetzner.svg new file mode 100644 index 000000000..68b1b868d --- /dev/null +++ b/public/svgs/hetzner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/svgs/homebox.svg b/public/svgs/homebox.svg new file mode 100644 index 000000000..08670bbb9 --- /dev/null +++ b/public/svgs/homebox.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/svgs/hoarder.svg b/public/svgs/karakeep.svg similarity index 100% rename from public/svgs/hoarder.svg rename to public/svgs/karakeep.svg diff --git a/public/svgs/langfuse.png b/public/svgs/langfuse.png deleted file mode 100644 index 8dec0fe4a..000000000 Binary files a/public/svgs/langfuse.png and /dev/null differ diff --git a/public/svgs/langfuse.svg b/public/svgs/langfuse.svg new file mode 100644 index 000000000..b04e07490 --- /dev/null +++ b/public/svgs/langfuse.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/librechat.svg b/public/svgs/librechat.svg new file mode 100644 index 000000000..36a536d65 --- /dev/null +++ b/public/svgs/librechat.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/lobe-chat.png b/public/svgs/lobe-chat.png new file mode 100644 index 000000000..2d340df88 Binary files /dev/null and b/public/svgs/lobe-chat.png differ diff --git a/public/svgs/matrix.svg b/public/svgs/matrix.svg new file mode 100644 index 000000000..bc41720a2 --- /dev/null +++ b/public/svgs/matrix.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/svgs/miniflux.svg b/public/svgs/miniflux.svg new file mode 100644 index 000000000..33ae73a87 --- /dev/null +++ b/public/svgs/miniflux.svg @@ -0,0 +1 @@ +icon \ No newline at end of file diff --git a/public/svgs/netbird.png b/public/svgs/netbird.png new file mode 100644 index 000000000..1b2405c07 Binary files /dev/null and b/public/svgs/netbird.png differ diff --git a/public/svgs/newapi.png b/public/svgs/newapi.png new file mode 100644 index 000000000..f62bfd57f Binary files /dev/null and b/public/svgs/newapi.png differ diff --git a/public/svgs/once-campfire.png b/public/svgs/once-campfire.png new file mode 100644 index 000000000..d1158445b Binary files /dev/null and b/public/svgs/once-campfire.png differ diff --git a/public/svgs/openpanel.svg b/public/svgs/openpanel.svg new file mode 100644 index 000000000..8508fc69e --- /dev/null +++ b/public/svgs/openpanel.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/orangehrm.svg b/public/svgs/orangehrm.svg new file mode 100644 index 000000000..b976d57ec --- /dev/null +++ b/public/svgs/orangehrm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/pihole.svg b/public/svgs/pihole.svg new file mode 100644 index 000000000..a4efefcc8 --- /dev/null +++ b/public/svgs/pihole.svg @@ -0,0 +1 @@ +NewVortex \ No newline at end of file diff --git a/public/svgs/pingvinshare.svg b/public/svgs/pingvinshare.svg new file mode 100644 index 000000000..4f1f7a7bc --- /dev/null +++ b/public/svgs/pingvinshare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/rybbit.svg b/public/svgs/rybbit.svg new file mode 100644 index 000000000..5715b29bd --- /dev/null +++ b/public/svgs/rybbit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/sequin.svg b/public/svgs/sequin.svg new file mode 100644 index 000000000..623bc1159 --- /dev/null +++ b/public/svgs/sequin.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/svgs/signoz.svg b/public/svgs/signoz.svg new file mode 100644 index 000000000..ac47e1c93 --- /dev/null +++ b/public/svgs/signoz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/swetrix.svg b/public/svgs/swetrix.svg new file mode 100644 index 000000000..8bb5bfdfa --- /dev/null +++ b/public/svgs/swetrix.svg @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/public/svgs/triliumnext.svg b/public/svgs/triliumnext.svg new file mode 100644 index 000000000..173712891 --- /dev/null +++ b/public/svgs/triliumnext.svg @@ -0,0 +1,28 @@ + + + TriliumNext Notes + + + + + + + + + + + + + + + diff --git a/public/vendor/telescope/app-dark.css b/public/vendor/telescope/app-dark.css index 70a872a92..9559860e7 100644 --- a/public/vendor/telescope/app-dark.css +++ b/public/vendor/telescope/app-dark.css @@ -1,10 +1,8 @@ -@charset "UTF-8"; - -.form-control:-moz-focusring{text-shadow:none!important} +@charset "UTF-8";.form-control:-moz-focusring{text-shadow:none!important} /*! * Bootstrap v4.6.2 (https://getbootstrap.com/) * Copyright 2011-2022 The Bootstrap Authors * Copyright 2011-2022 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#4b5563;--gray-dark:#1f2937;--primary:#4040c8;--secondary:#4b5563;--success:#059669;--info:#2563eb;--warning:#d97706;--danger:#dc2626;--light:#f3f4f6;--dark:#1f2937;--breakpoint-xs:0;--breakpoint-sm:2px;--breakpoint-md:8px;--breakpoint-lg:9px;--breakpoint-xl:10px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,:after,:before{box-sizing:border-box}html{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);font-family:sans-serif;line-height:1.15}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{background-color:#111827;color:#f3f4f6;font-family:Figtree,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;margin:0;text-align:left}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;margin-top:0}p{margin-bottom:1rem;margin-top:0}abbr[data-original-title],abbr[title]{border-bottom:0;cursor:help;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit}address,dl,ol,ul{margin-bottom:1rem}dl,ol,ul{margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:600}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{background-color:transparent;color:#818cf8;text-decoration:none}a:hover{color:#a5b4fc;text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}pre{-ms-overflow-style:scrollbar;margin-bottom:1rem;margin-top:0;overflow:auto}figure{margin:0 0 1rem}img{border-style:none}img,svg{vertical-align:middle}svg{overflow:hidden}table{border-collapse:collapse}caption{caption-side:bottom;color:#9ca3af;padding-bottom:.75rem;padding-top:.75rem;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{color:inherit;display:block;font-size:1.5rem;line-height:inherit;margin-bottom:.5rem;max-width:100%;padding:0;white-space:normal;width:100%}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:none;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}output{display:inline-block}summary{cursor:pointer;display:list-item}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-weight:500;line-height:1.2;margin-bottom:.5rem}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem}.display-1,.display-2{font-weight:300;line-height:1.2}.display-2{font-size:5.5rem}.display-3{font-size:4.5rem}.display-3,.display-4{font-weight:300;line-height:1.2}.display-4{font-size:3.5rem}hr{border:0;border-top:1px solid rgba(0,0,0,.1);margin-bottom:1rem;margin-top:1rem}.small,small{font-size:.875em;font-weight:400}.mark,mark{background-color:#fcf8e3;padding:.2em}.list-inline,.list-unstyled{list-style:none;padding-left:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{font-size:1.25rem;margin-bottom:1rem}.blockquote-footer{color:#4b5563;display:block;font-size:.875em}.blockquote-footer:before{content:"— "}.img-fluid,.img-thumbnail{height:auto;max-width:100%}.img-thumbnail{background-color:#111827;border:1px solid #d1d5db;border-radius:.25rem;padding:.25rem}.figure{display:inline-block}.figure-img{line-height:1;margin-bottom:.5rem}.figure-caption{color:#4b5563;font-size:90%}code{word-wrap:break-word;color:#e83e8c;font-size:87.5%}a>code{color:inherit}kbd{background-color:#111827;border-radius:.2rem;color:#fff;font-size:87.5%;padding:.2rem .4rem}kbd kbd{font-size:100%;font-weight:600;padding:0}pre{color:#111827;display:block;font-size:87.5%}pre code{color:inherit;font-size:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{margin-left:auto;margin-right:auto;padding-left:15px;padding-right:15px;width:100%}@media (min-width:2px){.container,.container-sm{max-width:1137px}}@media (min-width:8px){.container,.container-md,.container-sm{max-width:1138px}}@media (min-width:9px){.container,.container-lg,.container-md,.container-sm{max-width:1139px}}@media (min-width:10px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:flex;flex-wrap:wrap;margin-left:-15px;margin-right:-15px}.no-gutters{margin-left:0;margin-right:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-left:0;padding-right:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{padding-left:15px;padding-right:15px;position:relative;width:100%}.col{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-1>*{flex:0 0 100%;max-width:100%}.row-cols-2>*{flex:0 0 50%;max-width:50%}.row-cols-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-4>*{flex:0 0 25%;max-width:25%}.row-cols-5>*{flex:0 0 20%;max-width:20%}.row-cols-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-auto{flex:0 0 auto;max-width:100%;width:auto}.col-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-3{flex:0 0 25%;max-width:25%}.col-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-6{flex:0 0 50%;max-width:50%}.col-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-9{flex:0 0 75%;max-width:75%}.col-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-12{flex:0 0 100%;max-width:100%}.order-first{order:-1}.order-last{order:13}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-9{order:9}.order-10{order:10}.order-11{order:11}.order-12{order:12}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}@media (min-width:2px){.col-sm{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-sm-1>*{flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-auto{flex:0 0 auto;max-width:100%;width:auto}.col-sm-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-sm-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-sm-3{flex:0 0 25%;max-width:25%}.col-sm-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-sm-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-sm-6{flex:0 0 50%;max-width:50%}.col-sm-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-sm-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-sm-9{flex:0 0 75%;max-width:75%}.col-sm-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-sm-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-sm-12{flex:0 0 100%;max-width:100%}.order-sm-first{order:-1}.order-sm-last{order:13}.order-sm-0{order:0}.order-sm-1{order:1}.order-sm-2{order:2}.order-sm-3{order:3}.order-sm-4{order:4}.order-sm-5{order:5}.order-sm-6{order:6}.order-sm-7{order:7}.order-sm-8{order:8}.order-sm-9{order:9}.order-sm-10{order:10}.order-sm-11{order:11}.order-sm-12{order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}}@media (min-width:8px){.col-md{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-md-1>*{flex:0 0 100%;max-width:100%}.row-cols-md-2>*{flex:0 0 50%;max-width:50%}.row-cols-md-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-md-4>*{flex:0 0 25%;max-width:25%}.row-cols-md-5>*{flex:0 0 20%;max-width:20%}.row-cols-md-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-auto{flex:0 0 auto;max-width:100%;width:auto}.col-md-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-md-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-md-3{flex:0 0 25%;max-width:25%}.col-md-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-md-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-md-6{flex:0 0 50%;max-width:50%}.col-md-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-md-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-md-9{flex:0 0 75%;max-width:75%}.col-md-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-md-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-md-12{flex:0 0 100%;max-width:100%}.order-md-first{order:-1}.order-md-last{order:13}.order-md-0{order:0}.order-md-1{order:1}.order-md-2{order:2}.order-md-3{order:3}.order-md-4{order:4}.order-md-5{order:5}.order-md-6{order:6}.order-md-7{order:7}.order-md-8{order:8}.order-md-9{order:9}.order-md-10{order:10}.order-md-11{order:11}.order-md-12{order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}}@media (min-width:9px){.col-lg{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-lg-1>*{flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-auto{flex:0 0 auto;max-width:100%;width:auto}.col-lg-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-lg-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-lg-3{flex:0 0 25%;max-width:25%}.col-lg-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-lg-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-lg-6{flex:0 0 50%;max-width:50%}.col-lg-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-lg-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-lg-9{flex:0 0 75%;max-width:75%}.col-lg-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-lg-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-lg-12{flex:0 0 100%;max-width:100%}.order-lg-first{order:-1}.order-lg-last{order:13}.order-lg-0{order:0}.order-lg-1{order:1}.order-lg-2{order:2}.order-lg-3{order:3}.order-lg-4{order:4}.order-lg-5{order:5}.order-lg-6{order:6}.order-lg-7{order:7}.order-lg-8{order:8}.order-lg-9{order:9}.order-lg-10{order:10}.order-lg-11{order:11}.order-lg-12{order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}}@media (min-width:10px){.col-xl{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-xl-1>*{flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-auto{flex:0 0 auto;max-width:100%;width:auto}.col-xl-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-xl-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-xl-3{flex:0 0 25%;max-width:25%}.col-xl-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-xl-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-xl-6{flex:0 0 50%;max-width:50%}.col-xl-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-xl-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-xl-9{flex:0 0 75%;max-width:75%}.col-xl-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-xl-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-xl-12{flex:0 0 100%;max-width:100%}.order-xl-first{order:-1}.order-xl-last{order:13}.order-xl-0{order:0}.order-xl-1{order:1}.order-xl-2{order:2}.order-xl-3{order:3}.order-xl-4{order:4}.order-xl-5{order:5}.order-xl-6{order:6}.order-xl-7{order:7}.order-xl-8{order:8}.order-xl-9{order:9}.order-xl-10{order:10}.order-xl-11{order:11}.order-xl-12{order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}}.table{color:#f3f4f6;margin-bottom:1rem;width:100%}.table td,.table th{border-top:1px solid #374151;padding:.75rem;vertical-align:top}.table thead th{border-bottom:2px solid #374151;vertical-align:bottom}.table tbody+tbody{border-top:2px solid #374151}.table-sm td,.table-sm th{padding:.3rem}.table-bordered,.table-bordered td,.table-bordered th{border:1px solid #374151}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{background-color:#374151;color:#f3f4f6}.table-primary,.table-primary>td,.table-primary>th{background-color:#cacaf0}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#9c9ce2}.table-hover .table-primary:hover,.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b6b6ea}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#cdcfd3}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#a1a7ae}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#bfc2c7}.table-success,.table-success>td,.table-success>th{background-color:#b9e2d5}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#7dc8b1}.table-hover .table-success:hover,.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#a7dbca}.table-info,.table-info>td,.table-info>th{background-color:#c2d3f9}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#8eaef5}.table-hover .table-info:hover,.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abc2f7}.table-warning,.table-warning>td,.table-warning>th{background-color:#f4d9b9}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ebb87e}.table-hover .table-warning:hover,.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#f1cda3}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c2c2}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed8e8e}.table-hover .table-danger:hover,.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1acac}.table-light,.table-light>td,.table-light>th{background-color:#fcfcfc}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#f9f9fa}.table-hover .table-light:hover,.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#efefef}.table-dark,.table-dark>td,.table-dark>th{background-color:#c0c3c7}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#8b9097}.table-hover .table-dark:hover,.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b3b6bb}.table-active,.table-active>td,.table-active>th{background-color:#374151}.table-hover .table-active:hover,.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:#2d3542}.table .thead-dark th{background-color:#1f2937;border-color:#2d3b4f;color:#fff}.table .thead-light th{background-color:#e5e7eb;border-color:#374151;color:#374151}.table-dark{background-color:#1f2937;color:#fff}.table-dark td,.table-dark th,.table-dark thead th{border-color:#2d3b4f}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:hsla(0,0%,100%,.05)}.table-dark.table-hover tbody tr:hover{background-color:hsla(0,0%,100%,.075);color:#fff}@media (max-width:1.98px){.table-responsive-sm{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:7.98px){.table-responsive-md{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-md>.table-bordered{border:0}}@media (max-width:8.98px){.table-responsive-lg{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:9.98px){.table-responsive-xl{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive>.table-bordered{border:0}.form-control{background-clip:padding-box;background-color:#1f2937;border:1px solid #4b5563;border-radius:.25rem;color:#e5e7eb;display:block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem .75rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{background-color:#1f2937;border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);color:#e5e7eb;outline:0}.form-control::-moz-placeholder{color:#4b5563;opacity:1}.form-control::placeholder{color:#4b5563;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e5e7eb;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #e5e7eb}select.form-control:focus::-ms-value{background-color:#1f2937;color:#e5e7eb}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{font-size:inherit;line-height:1.5;margin-bottom:0;padding-bottom:calc(.375rem + 1px);padding-top:calc(.375rem + 1px)}.col-form-label-lg{font-size:1.25rem;line-height:1.5;padding-bottom:calc(.5rem + 1px);padding-top:calc(.5rem + 1px)}.col-form-label-sm{font-size:.875rem;line-height:1.5;padding-bottom:calc(.25rem + 1px);padding-top:calc(.25rem + 1px)}.form-control-plaintext{background-color:transparent;border:solid transparent;border-width:1px 0;color:#f3f4f6;display:block;font-size:1rem;line-height:1.5;margin-bottom:0;padding:.375rem 0;width:100%}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-left:0;padding-right:0}.form-control-sm{border-radius:.2rem;font-size:.875rem;height:calc(1.5em + .5rem + 2px);line-height:1.5;padding:.25rem .5rem}.form-control-lg{border-radius:6px;font-size:1.25rem;height:calc(1.5em + 1rem + 2px);line-height:1.5;padding:.5rem 1rem}select.form-control[multiple],select.form-control[size],textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:flex;flex-wrap:wrap;margin-left:-5px;margin-right:-5px}.form-row>.col,.form-row>[class*=col-]{padding-left:5px;padding-right:5px}.form-check{display:block;padding-left:1.25rem;position:relative}.form-check-input{margin-left:-1.25rem;margin-top:.3rem;position:absolute}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#9ca3af}.form-check-label{margin-bottom:0}.form-check-inline{align-items:center;display:inline-flex;margin-right:.75rem;padding-left:0}.form-check-inline .form-check-input{margin-left:0;margin-right:.3125rem;margin-top:0;position:static}.valid-feedback{color:#059669;display:none;font-size:.875em;margin-top:.25rem;width:100%}.valid-tooltip{background-color:rgba(5,150,105,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.valid-tooltip,.form-row>[class*=col-]>.valid-tooltip{left:5px}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#059669;padding-right:calc(1.5em + .75rem)!important}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.was-validated select.form-control:valid,select.form-control.is-valid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-valid,.was-validated .custom-select:valid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#1f2937 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#059669;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#059669}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#059669}.custom-control-input.is-valid~.custom-control-label:before,.was-validated .custom-control-input:valid~.custom-control-label:before{border-color:#059669}.custom-control-input.is-valid:checked~.custom-control-label:before,.was-validated .custom-control-input:valid:checked~.custom-control-label:before{background-color:#07c78c;border-color:#07c78c}.custom-control-input.is-valid:focus~.custom-control-label:before,.was-validated .custom-control-input:valid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label:before{border-color:#059669}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#059669}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.invalid-feedback{color:#dc2626;display:none;font-size:.875em;margin-top:.25rem;width:100%}.invalid-tooltip{background-color:rgba(220,38,38,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.invalid-tooltip,.form-row>[class*=col-]>.invalid-tooltip{left:5px}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#dc2626;padding-right:calc(1.5em + .75rem)!important}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.was-validated select.form-control:invalid,select.form-control.is-invalid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#1f2937 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#dc2626;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc2626}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc2626}.custom-control-input.is-invalid~.custom-control-label:before,.was-validated .custom-control-input:invalid~.custom-control-label:before{border-color:#dc2626}.custom-control-input.is-invalid:checked~.custom-control-label:before,.was-validated .custom-control-input:invalid:checked~.custom-control-label:before{background-color:#e35252;border-color:#e35252}.custom-control-input.is-invalid:focus~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label:before{border-color:#dc2626}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc2626}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-inline{align-items:center;display:flex;flex-flow:row wrap}.form-inline .form-check{width:100%}@media (min-width:2px){.form-inline label{justify-content:center}.form-inline .form-group,.form-inline label{align-items:center;display:flex;margin-bottom:0}.form-inline .form-group{flex:0 0 auto;flex-flow:row wrap}.form-inline .form-control{display:inline-block;vertical-align:middle;width:auto}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{align-items:center;display:flex;justify-content:center;padding-left:0;width:auto}.form-inline .form-check-input{flex-shrink:0;margin-left:0;margin-right:.25rem;margin-top:0;position:relative}.form-inline .custom-control{align-items:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{background-color:transparent;border:1px solid transparent;border-radius:.25rem;color:#f3f4f6;display:inline-block;font-size:1rem;font-weight:400;line-height:1.5;padding:.375rem .75rem;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#f3f4f6;text-decoration:none}.btn.focus,.btn:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary.focus,.btn-primary:focus,.btn-primary:hover{background-color:#3232af;border-color:#3030a5;color:#fff}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(93,93,208,.5)}.btn-primary.disabled,.btn-primary:disabled{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{background-color:#3030a5;border-color:#2d2d9b;color:#fff}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(93,93,208,.5)}.btn-secondary{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary.focus,.btn-secondary:focus,.btn-secondary:hover{background-color:#3b424d;border-color:#353c46;color:#fff}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem hsla(213,9%,44%,.5)}.btn-secondary.disabled,.btn-secondary:disabled{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{background-color:#353c46;border-color:#30363f;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem hsla(213,9%,44%,.5)}.btn-success{background-color:#059669;border-color:#059669;color:#fff}.btn-success.focus,.btn-success:focus,.btn-success:hover{background-color:#04714f;border-color:#036546;color:#fff}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(43,166,128,.5)}.btn-success.disabled,.btn-success:disabled{background-color:#059669;border-color:#059669;color:#fff}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{background-color:#036546;border-color:#03583e;color:#fff}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(43,166,128,.5)}.btn-info{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info.focus,.btn-info:focus,.btn-info:hover{background-color:#1451d6;border-color:#134cca;color:#fff}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(70,122,238,.5)}.btn-info.disabled,.btn-info:disabled{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{background-color:#134cca;border-color:#1248bf;color:#fff}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(70,122,238,.5)}.btn-warning{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning.focus,.btn-warning:focus,.btn-warning:hover{background-color:#b46305;border-color:#a75c05;color:#fff}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(223,139,43,.5)}.btn-warning.disabled,.btn-warning:disabled{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{background-color:#a75c05;border-color:#9b5504;color:#fff}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(223,139,43,.5)}.btn-danger{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger.focus,.btn-danger:focus,.btn-danger:hover{background-color:#bd1f1f;border-color:#b21d1d;color:#fff}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(225,71,71,.5)}.btn-danger.disabled,.btn-danger:disabled{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{background-color:#b21d1d;border-color:#a71b1b;color:#fff}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,71,71,.5)}.btn-light{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light.focus,.btn-light:focus,.btn-light:hover{background-color:#dde0e6;border-color:#d6d9e0;color:#111827}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem hsla(220,7%,83%,.5)}.btn-light.disabled,.btn-light:disabled{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{background-color:#d6d9e0;border-color:#cfd3db;color:#111827}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem hsla(220,7%,83%,.5)}.btn-dark{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark.focus,.btn-dark:focus,.btn-dark:hover{background-color:#11171f;border-color:#0d1116;color:#fff}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(65,73,85,.5)}.btn-dark.disabled,.btn-dark:disabled{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{background-color:#0d1116;border-color:#080b0e;color:#fff}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(65,73,85,.5)}.btn-outline-primary{border-color:#4040c8;color:#4040c8}.btn-outline-primary:hover{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{background-color:transparent;color:#4040c8}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5)}.btn-outline-secondary{border-color:#4b5563;color:#4b5563}.btn-outline-secondary:hover{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{background-color:transparent;color:#4b5563}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5)}.btn-outline-success{border-color:#059669;color:#059669}.btn-outline-success:hover{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{background-color:transparent;color:#059669}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5)}.btn-outline-info{border-color:#2563eb;color:#2563eb}.btn-outline-info:hover{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{background-color:transparent;color:#2563eb}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5)}.btn-outline-warning{border-color:#d97706;color:#d97706}.btn-outline-warning:hover{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{background-color:transparent;color:#d97706}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5)}.btn-outline-danger{border-color:#dc2626;color:#dc2626}.btn-outline-danger:hover{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{background-color:transparent;color:#dc2626}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5)}.btn-outline-light{border-color:#f3f4f6;color:#f3f4f6}.btn-outline-light:hover{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{background-color:transparent;color:#f3f4f6}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5)}.btn-outline-dark{border-color:#1f2937;color:#1f2937}.btn-outline-dark:hover{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{background-color:transparent;color:#1f2937}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5)}.btn-link{color:#818cf8;font-weight:400;text-decoration:none}.btn-link:hover{color:#a5b4fc}.btn-link.focus,.btn-link:focus,.btn-link:hover{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#4b5563;pointer-events:none}.btn-group-lg>.btn,.btn-lg{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.btn-group-sm>.btn,.btn-sm{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;position:relative;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.width{height:auto;transition:width .35s ease;width:0}@media (prefers-reduced-motion:reduce){.collapsing.width{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle:after{border-bottom:0;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropdown-toggle:empty:after{margin-left:0}.dropdown-menu{background-clip:padding-box;background-color:#374151;border:1px solid rgba(0,0,0,.15);border-radius:.25rem;color:#f3f4f6;display:none;float:left;font-size:1rem;left:0;list-style:none;margin:.125rem 0 0;min-width:10rem;padding:.5rem 0;position:absolute;text-align:left;top:100%;z-index:1000}.dropdown-menu-left{left:0;right:auto}.dropdown-menu-right{left:auto;right:0}@media (min-width:2px){.dropdown-menu-sm-left{left:0;right:auto}.dropdown-menu-sm-right{left:auto;right:0}}@media (min-width:8px){.dropdown-menu-md-left{left:0;right:auto}.dropdown-menu-md-right{left:auto;right:0}}@media (min-width:9px){.dropdown-menu-lg-left{left:0;right:auto}.dropdown-menu-lg-right{left:auto;right:0}}@media (min-width:10px){.dropdown-menu-xl-left{left:0;right:auto}.dropdown-menu-xl-right{left:auto;right:0}}.dropup .dropdown-menu{bottom:100%;margin-bottom:.125rem;margin-top:0;top:auto}.dropup .dropdown-toggle:after{border-bottom:.3em solid;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:0;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropup .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-menu{left:100%;margin-left:.125rem;margin-top:0;right:auto;top:0}.dropright .dropdown-toggle:after{border-bottom:.3em solid transparent;border-left:.3em solid;border-right:0;border-top:.3em solid transparent;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropright .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-toggle:after{vertical-align:0}.dropleft .dropdown-menu{left:auto;margin-right:.125rem;margin-top:0;right:100%;top:0}.dropleft .dropdown-toggle:after{content:"";display:inline-block;display:none;margin-left:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:before{border-bottom:.3em solid transparent;border-right:.3em solid;border-top:.3em solid transparent;content:"";display:inline-block;margin-right:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:empty:after{margin-left:0}.dropleft .dropdown-toggle:before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{bottom:auto;right:auto}.dropdown-divider{border-top:1px solid #e5e7eb;height:0;margin:.5rem 0;overflow:hidden}.dropdown-item{background-color:transparent;border:0;clear:both;color:#fff;display:block;font-weight:400;padding:.25rem 1.5rem;text-align:inherit;white-space:nowrap;width:100%}.dropdown-item:focus,.dropdown-item:hover{background-color:#e5e7eb;color:#090d15;text-decoration:none}.dropdown-item.active,.dropdown-item:active{background-color:#4040c8;color:#fff;text-decoration:none}.dropdown-item.disabled,.dropdown-item:disabled{background-color:transparent;color:#6b7280;pointer-events:none}.dropdown-menu.show{display:block}.dropdown-header{color:#4b5563;display:block;font-size:.875rem;margin-bottom:0;padding:.5rem 1.5rem;white-space:nowrap}.dropdown-item-text{color:#fff;display:block;padding:.25rem 1.5rem}.btn-group,.btn-group-vertical{display:inline-flex;position:relative;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{flex:1 1 auto;position:relative}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.dropdown-toggle-split{padding-left:.5625rem;padding-right:.5625rem}.dropdown-toggle-split:after,.dropright .dropdown-toggle-split:after,.dropup .dropdown-toggle-split:after{margin-left:0}.dropleft .dropdown-toggle-split:before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-left:.375rem;padding-right:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-left:.75rem;padding-right:.75rem}.btn-group-vertical{align-items:flex-start;flex-direction:column;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-left-radius:0;border-bottom-right-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{clip:rect(0,0,0,0);pointer-events:none;position:absolute}.input-group{align-items:stretch;display:flex;flex-wrap:wrap;position:relative;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{flex:1 1 auto;margin-bottom:0;min-width:0;position:relative;width:1%}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.input-group>.custom-file{align-items:center;display:flex}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label:after{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-bottom-left-radius:0;border-top-left-radius:0}.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label,.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label:after,.input-group.has-validation>.custom-select:nth-last-child(n+3),.input-group.has-validation>.form-control:nth-last-child(n+3),.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label,.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label:after,.input-group:not(.has-validation)>.custom-select:not(:last-child),.input-group:not(.has-validation)>.form-control:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group-append,.input-group-prepend{display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{align-items:center;background-color:#e5e7eb;border:1px solid #4b5563;border-radius:.25rem;color:#e5e7eb;display:flex;font-size:1rem;font-weight:400;line-height:1.5;margin-bottom:0;padding:.375rem .75rem;text-align:center;white-space:nowrap}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.btn,.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.input-group-text,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.btn,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-bottom-left-radius:0;border-top-left-radius:0}.custom-control{display:block;min-height:1.5rem;padding-left:1.5rem;position:relative;-webkit-print-color-adjust:exact;print-color-adjust:exact;z-index:1}.custom-control-inline{display:inline-flex;margin-right:1rem}.custom-control-input{height:1.25rem;left:0;opacity:0;position:absolute;width:1rem;z-index:-1}.custom-control-input:checked~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8;color:#fff}.custom-control-input:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label:before{border-color:#a3a3e5}.custom-control-input:not(:disabled):active~.custom-control-label:before{background-color:#cbcbf0;border-color:#cbcbf0;color:#fff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#4b5563}.custom-control-input:disabled~.custom-control-label:before,.custom-control-input[disabled]~.custom-control-label:before{background-color:#e5e7eb}.custom-control-label{margin-bottom:0;position:relative;vertical-align:top}.custom-control-label:before{background-color:#1f2937;border:1px solid #6b7280;pointer-events:none}.custom-control-label:after,.custom-control-label:before{content:"";display:block;height:1rem;left:-1.5rem;position:absolute;top:.25rem;width:1rem}.custom-control-label:after{background:50%/50% 50% no-repeat}.custom-checkbox .custom-control-label:before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23fff' d='m6.564.75-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-radio .custom-control-label:before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label:before{border-radius:.5rem;left:-2.25rem;pointer-events:all;width:1.75rem}.custom-switch .custom-control-label:after{background-color:#6b7280;border-radius:.5rem;height:calc(1rem - 4px);left:calc(-2.25rem + 2px);top:calc(.25rem + 2px);transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:calc(1rem - 4px)}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label:after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label:after{background-color:#1f2937;transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#1f2937 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat;border:1px solid #4b5563;border-radius:.25rem;color:#e5e7eb;display:inline-block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem 1.75rem .375rem .75rem;vertical-align:middle;width:100%}.custom-select:focus{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.custom-select:focus::-ms-value{background-color:#1f2937;color:#e5e7eb}.custom-select[multiple],.custom-select[size]:not([size="1"]){background-image:none;height:auto;padding-right:.75rem}.custom-select:disabled{background-color:#e5e7eb;color:#4b5563}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #e5e7eb}.custom-select-sm{font-size:.875rem;height:calc(1.5em + .5rem + 2px);padding-bottom:.25rem;padding-left:.5rem;padding-top:.25rem}.custom-select-lg{font-size:1.25rem;height:calc(1.5em + 1rem + 2px);padding-bottom:.5rem;padding-left:1rem;padding-top:.5rem}.custom-file{display:inline-block;margin-bottom:0}.custom-file,.custom-file-input{height:calc(1.5em + .75rem + 2px);position:relative;width:100%}.custom-file-input{margin:0;opacity:0;overflow:hidden;z-index:2}.custom-file-input:focus~.custom-file-label{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e5e7eb}.custom-file-input:lang(en)~.custom-file-label:after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]:after{content:attr(data-browse)}.custom-file-label{background-color:#1f2937;border:1px solid #4b5563;border-radius:.25rem;font-weight:400;height:calc(1.5em + .75rem + 2px);left:0;overflow:hidden;z-index:1}.custom-file-label,.custom-file-label:after{color:#e5e7eb;line-height:1.5;padding:.375rem .75rem;position:absolute;right:0;top:0}.custom-file-label:after{background-color:#e5e7eb;border-left:inherit;border-radius:0 .25rem .25rem 0;bottom:0;content:"Browse";display:block;height:calc(1.5em + .75rem);z-index:3}.custom-range{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;height:1.4rem;padding:0;width:100%}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #111827,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #111827,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #111827,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-top:-.25rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#cbcbf0}.custom-range::-webkit-slider-runnable-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-moz-range-thumb{-moz-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#cbcbf0}.custom-range::-moz-range-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-thumb{appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-left:.2rem;margin-right:.2rem;margin-top:0;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#cbcbf0}.custom-range::-ms-track{background-color:transparent;border-color:transparent;border-width:.5rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-fill-lower,.custom-range::-ms-fill-upper{background-color:#d1d5db;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px}.custom-range:disabled::-webkit-slider-thumb{background-color:#6b7280}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#6b7280}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#6b7280}.custom-control-label:before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label:before,.custom-file-label,.custom-select{transition:none}}.nav{display:flex;flex-wrap:wrap;list-style:none;margin-bottom:0;padding-left:0}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#4b5563;cursor:default;pointer-events:none}.nav-tabs{border-bottom:1px solid #d1d5db}.nav-tabs .nav-link{background-color:transparent;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem;margin-bottom:-1px}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e5e7eb #e5e7eb #d1d5db;isolation:isolate}.nav-tabs .nav-link.disabled{background-color:transparent;border-color:transparent;color:#4b5563}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{background-color:#111827;border-color:#d1d5db #d1d5db #111827;color:#374151}.nav-tabs .dropdown-menu{border-top-left-radius:0;border-top-right-radius:0;margin-top:-1px}.nav-pills .nav-link{background:none;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{background-color:#1f2937;color:#fff}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{padding:.5rem 1rem;position:relative}.navbar,.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{align-items:center;display:flex;flex-wrap:wrap;justify-content:space-between}.navbar-brand{display:inline-block;font-size:1.25rem;line-height:inherit;margin-right:1rem;padding-bottom:.3125rem;padding-top:.3125rem;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:flex;flex-direction:column;list-style:none;margin-bottom:0;padding-left:0}.navbar-nav .nav-link{padding-left:0;padding-right:0}.navbar-nav .dropdown-menu{float:none;position:static}.navbar-text{display:inline-block;padding-bottom:.5rem;padding-top:.5rem}.navbar-collapse{align-items:center;flex-basis:100%;flex-grow:1}.navbar-toggler{background-color:transparent;border:1px solid transparent;border-radius:.25rem;font-size:1.25rem;line-height:1;padding:.25rem .75rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{background:50%/100% 100% no-repeat;content:"";display:inline-block;height:1.5em;vertical-align:middle;width:1.5em}.navbar-nav-scroll{max-height:75vh;overflow-y:auto}@media (max-width:1.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-left:0;padding-right:0}}@media (min-width:2px){.navbar-expand-sm{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{flex-wrap:nowrap}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:7.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-left:0;padding-right:0}}@media (min-width:8px){.navbar-expand-md{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{flex-wrap:nowrap}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:8.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-left:0;padding-right:0}}@media (min-width:9px){.navbar-expand-lg{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{flex-wrap:nowrap}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:9.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-left:0;padding-right:0}}@media (min-width:10px){.navbar-expand-xl{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{flex-wrap:nowrap}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-left:0;padding-right:0}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{flex-wrap:nowrap}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand,.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{border-color:rgba(0,0,0,.1);color:rgba(0,0,0,.5)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand,.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:hsla(0,0%,100%,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:hsla(0,0%,100%,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{border-color:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-dark .navbar-text{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{word-wrap:break-word;background-clip:border-box;background-color:#1f2937;border:1px solid rgba(0,0,0,.125);border-radius:6px;display:flex;flex-direction:column;min-width:0;position:relative}.card>hr{margin-left:0;margin-right:0}.card>.list-group{border-bottom:inherit;border-top:inherit}.card>.list-group:first-child{border-top-left-radius:5px;border-top-right-radius:5px;border-top-width:0}.card>.list-group:last-child{border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-bottom-width:0}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{background-color:#374151;border-bottom:1px solid rgba(0,0,0,.125);margin-bottom:0;padding:.75rem 1.25rem}.card-header:first-child{border-radius:5px 5px 0 0}.card-footer{background-color:#374151;border-top:1px solid rgba(0,0,0,.125);padding:.75rem 1.25rem}.card-footer:last-child{border-radius:0 0 5px 5px}.card-header-tabs{border-bottom:0;margin-bottom:-.75rem}.card-header-pills,.card-header-tabs{margin-left:-.625rem;margin-right:-.625rem}.card-img-overlay{border-radius:5px;bottom:0;left:0;padding:1.25rem;position:absolute;right:0;top:0}.card-img,.card-img-bottom,.card-img-top{flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:5px;border-top-right-radius:5px}.card-img,.card-img-bottom{border-bottom-left-radius:5px;border-bottom-right-radius:5px}.card-deck .card{margin-bottom:15px}@media (min-width:2px){.card-deck{display:flex;flex-flow:row wrap;margin-left:-15px;margin-right:-15px}.card-deck .card{flex:1 0 0%;margin-bottom:0;margin-left:15px;margin-right:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:2px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{border-left:0;margin-left:0}.card-group>.card:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:2px){.card-columns{-moz-column-count:3;column-count:3;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{background-color:#e5e7eb;border-radius:.25rem;display:flex;flex-wrap:wrap;list-style:none;margin-bottom:1rem;padding:.75rem 1rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item:before{color:#4b5563;content:"/";float:left;padding-right:.5rem}.breadcrumb-item+.breadcrumb-item:hover:before{text-decoration:underline;text-decoration:none}.breadcrumb-item.active{color:#4b5563}.pagination{border-radius:.25rem;display:flex;list-style:none;padding-left:0}.page-link{background-color:#fff;border:1px solid #d1d5db;color:#818cf8;display:block;line-height:1.25;margin-left:-1px;padding:.5rem .75rem;position:relative}.page-link:hover{background-color:#e5e7eb;border-color:#d1d5db;color:#a5b4fc;text-decoration:none;z-index:2}.page-link:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0;z-index:3}.page-item:first-child .page-link{border-bottom-left-radius:.25rem;border-top-left-radius:.25rem;margin-left:0}.page-item:last-child .page-link{border-bottom-right-radius:.25rem;border-top-right-radius:.25rem}.page-item.active .page-link{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:3}.page-item.disabled .page-link{background-color:#fff;border-color:#d1d5db;color:#4b5563;cursor:auto;pointer-events:none}.pagination-lg .page-link{font-size:1.25rem;line-height:1.5;padding:.75rem 1.5rem}.pagination-lg .page-item:first-child .page-link{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg .page-item:last-child .page-link{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm .page-link{font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.pagination-sm .page-item:first-child .page-link{border-bottom-left-radius:.2rem;border-top-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-bottom-right-radius:.2rem;border-top-right-radius:.2rem}.badge{border-radius:.25rem;display:inline-block;font-size:.875rem;font-weight:600;line-height:1;padding:.25em .4em;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;vertical-align:baseline;white-space:nowrap}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{border-radius:10rem;padding-left:.6em;padding-right:.6em}.badge-primary{background-color:#4040c8;color:#fff}a.badge-primary:focus,a.badge-primary:hover{background-color:#3030a5;color:#fff}a.badge-primary.focus,a.badge-primary:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5);outline:0}.badge-secondary{background-color:#4b5563;color:#fff}a.badge-secondary:focus,a.badge-secondary:hover{background-color:#353c46;color:#fff}a.badge-secondary.focus,a.badge-secondary:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5);outline:0}.badge-success{background-color:#059669}a.badge-success:focus,a.badge-success:hover{background-color:#036546;color:#fff}a.badge-success.focus,a.badge-success:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5);outline:0}.badge-info{background-color:#2563eb}a.badge-info:focus,a.badge-info:hover{background-color:#134cca;color:#fff}a.badge-info.focus,a.badge-info:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5);outline:0}.badge-warning{background-color:#d97706}a.badge-warning:focus,a.badge-warning:hover{background-color:#a75c05;color:#fff}a.badge-warning.focus,a.badge-warning:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5);outline:0}.badge-danger{background-color:#dc2626}a.badge-danger:focus,a.badge-danger:hover{background-color:#b21d1d;color:#fff}a.badge-danger.focus,a.badge-danger:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5);outline:0}.badge-light{background-color:#f3f4f6;color:#111827}a.badge-light:focus,a.badge-light:hover{background-color:#d6d9e0;color:#111827}a.badge-light.focus,a.badge-light:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5);outline:0}.badge-dark{background-color:#1f2937;color:#fff}a.badge-dark:focus,a.badge-dark:hover{background-color:#0d1116;color:#fff}a.badge-dark.focus,a.badge-dark:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5);outline:0}.jumbotron{background-color:#e5e7eb;border-radius:6px;margin-bottom:2rem;padding:2rem 1rem}@media (min-width:2px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{border-radius:0;padding-left:0;padding-right:0}.alert{border:1px solid transparent;border-radius:.25rem;margin-bottom:1rem;padding:.75rem 1.25rem;position:relative}.alert-heading{color:inherit}.alert-link{font-weight:600}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{color:inherit;padding:.75rem 1.25rem;position:absolute;right:0;top:0;z-index:2}.alert-primary{background-color:#d9d9f4;border-color:#cacaf0;color:#212168}.alert-primary hr{border-top-color:#b6b6ea}.alert-primary .alert-link{color:#151541}.alert-secondary{background-color:#dbdde0;border-color:#cdcfd3;color:#272c33}.alert-secondary hr{border-top-color:#bfc2c7}.alert-secondary .alert-link{color:#111316}.alert-success{background-color:#cdeae1;border-color:#b9e2d5;color:#034e37}.alert-success hr{border-top-color:#a7dbca}.alert-success .alert-link{color:#011d14}.alert-info{background-color:#d3e0fb;border-color:#c2d3f9;color:#13337a}.alert-info hr{border-top-color:#abc2f7}.alert-info .alert-link{color:#0c214e}.alert-warning{background-color:#f7e4cd;border-color:#f4d9b9;color:#713e03}.alert-warning hr{border-top-color:#f1cda3}.alert-warning .alert-link{color:#3f2302}.alert-danger{background-color:#f8d4d4;border-color:#f5c2c2;color:#721414}.alert-danger hr{border-top-color:#f1acac}.alert-danger .alert-link{color:#470c0c}.alert-light{background-color:#fdfdfd;border-color:#fcfcfc;color:#7e7f80}.alert-light hr{border-top-color:#efefef}.alert-light .alert-link{color:#656666}.alert-dark{background-color:#d2d4d7;border-color:#c0c3c7;color:#10151d}.alert-dark hr{border-top-color:#b3b6bb}.alert-dark .alert-link{color:#000}@keyframes progress-bar-stripes{0%{background-position:1rem 0}to{background-position:0 0}}.progress{background-color:#e5e7eb;border-radius:.25rem;font-size:.75rem;height:1rem;line-height:0}.progress,.progress-bar{display:flex;overflow:hidden}.progress-bar{background-color:#4040c8;color:#fff;flex-direction:column;justify-content:center;text-align:center;transition:width .6s ease;white-space:nowrap}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent);background-size:1rem 1rem}.progress-bar-animated{animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.media{align-items:flex-start;display:flex}.media-body{flex:1}.list-group{border-radius:.25rem;display:flex;flex-direction:column;margin-bottom:0;padding-left:0}.list-group-item-action{color:#374151;text-align:inherit;width:100%}.list-group-item-action:focus,.list-group-item-action:hover{background-color:#f3f4f6;color:#374151;text-decoration:none;z-index:1}.list-group-item-action:active{background-color:#e5e7eb;color:#f3f4f6}.list-group-item{background-color:#fff;border:1px solid rgba(0,0,0,.125);display:block;padding:.75rem 1.25rem;position:relative}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{background-color:#fff;color:#4b5563;pointer-events:none}.list-group-item.active{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:2}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{border-top-width:1px;margin-top:-1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}@media (min-width:2px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:8px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-md>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:9px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:10px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{background-color:#cacaf0;color:#212168}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{background-color:#b6b6ea;color:#212168}.list-group-item-primary.list-group-item-action.active{background-color:#212168;border-color:#212168;color:#fff}.list-group-item-secondary{background-color:#cdcfd3;color:#272c33}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{background-color:#bfc2c7;color:#272c33}.list-group-item-secondary.list-group-item-action.active{background-color:#272c33;border-color:#272c33;color:#fff}.list-group-item-success{background-color:#b9e2d5;color:#034e37}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{background-color:#a7dbca;color:#034e37}.list-group-item-success.list-group-item-action.active{background-color:#034e37;border-color:#034e37;color:#fff}.list-group-item-info{background-color:#c2d3f9;color:#13337a}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{background-color:#abc2f7;color:#13337a}.list-group-item-info.list-group-item-action.active{background-color:#13337a;border-color:#13337a;color:#fff}.list-group-item-warning{background-color:#f4d9b9;color:#713e03}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{background-color:#f1cda3;color:#713e03}.list-group-item-warning.list-group-item-action.active{background-color:#713e03;border-color:#713e03;color:#fff}.list-group-item-danger{background-color:#f5c2c2;color:#721414}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{background-color:#f1acac;color:#721414}.list-group-item-danger.list-group-item-action.active{background-color:#721414;border-color:#721414;color:#fff}.list-group-item-light{background-color:#fcfcfc;color:#7e7f80}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{background-color:#efefef;color:#7e7f80}.list-group-item-light.list-group-item-action.active{background-color:#7e7f80;border-color:#7e7f80;color:#fff}.list-group-item-dark{background-color:#c0c3c7;color:#10151d}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{background-color:#b3b6bb;color:#10151d}.list-group-item-dark.list-group-item-action.active{background-color:#10151d;border-color:#10151d;color:#fff}.close{color:#000;float:right;font-size:1.5rem;font-weight:600;line-height:1;opacity:.5;text-shadow:0 1px 0 #fff}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{background-color:transparent;border:0;padding:0}a.close.disabled{pointer-events:none}.toast{background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border:1px solid rgba(0,0,0,.1);border-radius:.25rem;box-shadow:0 .25rem .75rem rgba(0,0,0,.1);flex-basis:350px;font-size:.875rem;max-width:350px;opacity:0}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{align-items:center;background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px);color:#4b5563;display:flex;padding:.25rem .75rem}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{display:none;height:100%;left:0;outline:0;overflow:hidden;position:fixed;top:0;width:100%;z-index:1050}.modal-dialog{margin:.5rem;pointer-events:none;position:relative;width:auto}.modal.fade .modal-dialog{transform:translateY(-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{align-items:center;display:flex;min-height:calc(100% - 1rem)}.modal-dialog-centered:before{content:"";display:block;height:calc(100vh - 1rem);height:-moz-min-content;height:min-content}.modal-dialog-centered.modal-dialog-scrollable{flex-direction:column;height:100%;justify-content:center}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable:before{content:none}.modal-content{background-clip:padding-box;background-color:#1f2937;border:1px solid rgba(0,0,0,.2);border-radius:6px;display:flex;flex-direction:column;outline:0;pointer-events:auto;position:relative;width:100%}.modal-backdrop{background-color:#4b5563;height:100vh;left:0;position:fixed;top:0;width:100vw;z-index:1040}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{align-items:flex-start;border-bottom:1px solid #4b5563;border-top-left-radius:5px;border-top-right-radius:5px;display:flex;justify-content:space-between;padding:1rem}.modal-header .close{margin:-1rem -1rem -1rem auto;padding:1rem}.modal-title{line-height:1.5;margin-bottom:0}.modal-body{flex:1 1 auto;padding:1rem;position:relative}.modal-footer{align-items:center;border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-top:1px solid #4b5563;display:flex;flex-wrap:wrap;justify-content:flex-end;padding:.75rem}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{height:50px;overflow:scroll;position:absolute;top:-9999px;width:50px}@media (min-width:2px){.modal-dialog{margin:1.75rem auto;max-width:500px}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered:before{height:calc(100vh - 3.5rem);height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:9px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:10px){.modal-xl{max-width:1140px}}.tooltip{word-wrap:break-word;display:block;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.5;margin:0;opacity:0;position:absolute;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;z-index:1070}.tooltip.show{opacity:.9}.tooltip .arrow{display:block;height:.4rem;position:absolute;width:.8rem}.tooltip .arrow:before{border-color:transparent;border-style:solid;content:"";position:absolute}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow:before,.bs-tooltip-top .arrow:before{border-top-color:#000;border-width:.4rem .4rem 0;top:0}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{height:.8rem;left:0;width:.4rem}.bs-tooltip-auto[x-placement^=right] .arrow:before,.bs-tooltip-right .arrow:before{border-right-color:#000;border-width:.4rem .4rem .4rem 0;right:0}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow:before,.bs-tooltip-bottom .arrow:before{border-bottom-color:#000;border-width:0 .4rem .4rem;bottom:0}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{height:.8rem;right:0;width:.4rem}.bs-tooltip-auto[x-placement^=left] .arrow:before,.bs-tooltip-left .arrow:before{border-left-color:#000;border-width:.4rem 0 .4rem .4rem;left:0}.tooltip-inner{background-color:#000;border-radius:.25rem;color:#fff;max-width:200px;padding:.25rem .5rem;text-align:center}.popover{word-wrap:break-word;background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.2);border-radius:6px;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;left:0;letter-spacing:normal;line-break:auto;line-height:1.5;max-width:276px;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;top:0;white-space:normal;word-break:normal;word-spacing:normal;z-index:1060}.popover,.popover .arrow{display:block;position:absolute}.popover .arrow{height:.5rem;margin:0 6px;width:1rem}.popover .arrow:after,.popover .arrow:before{border-color:transparent;border-style:solid;content:"";display:block;position:absolute}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow:before,.bs-popover-top>.arrow:before{border-top-color:rgba(0,0,0,.25);border-width:.5rem .5rem 0;bottom:0}.bs-popover-auto[x-placement^=top]>.arrow:after,.bs-popover-top>.arrow:after{border-top-color:#fff;border-width:.5rem .5rem 0;bottom:1px}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{height:1rem;left:calc(-.5rem - 1px);margin:6px 0;width:.5rem}.bs-popover-auto[x-placement^=right]>.arrow:before,.bs-popover-right>.arrow:before{border-right-color:rgba(0,0,0,.25);border-width:.5rem .5rem .5rem 0;left:0}.bs-popover-auto[x-placement^=right]>.arrow:after,.bs-popover-right>.arrow:after{border-right-color:#fff;border-width:.5rem .5rem .5rem 0;left:1px}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow:before,.bs-popover-bottom>.arrow:before{border-bottom-color:rgba(0,0,0,.25);border-width:0 .5rem .5rem;top:0}.bs-popover-auto[x-placement^=bottom]>.arrow:after,.bs-popover-bottom>.arrow:after{border-bottom-color:#fff;border-width:0 .5rem .5rem;top:1px}.bs-popover-auto[x-placement^=bottom] .popover-header:before,.bs-popover-bottom .popover-header:before{border-bottom:1px solid #f7f7f7;content:"";display:block;left:50%;margin-left:-.5rem;position:absolute;top:0;width:1rem}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{height:1rem;margin:6px 0;right:calc(-.5rem - 1px);width:.5rem}.bs-popover-auto[x-placement^=left]>.arrow:before,.bs-popover-left>.arrow:before{border-left-color:rgba(0,0,0,.25);border-width:.5rem 0 .5rem .5rem;right:0}.bs-popover-auto[x-placement^=left]>.arrow:after,.bs-popover-left>.arrow:after{border-left-color:#fff;border-width:.5rem 0 .5rem .5rem;right:1px}.popover-header{background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:5px;border-top-right-radius:5px;font-size:1rem;margin-bottom:0;padding:.5rem .75rem}.popover-header:empty{display:none}.popover-body{color:#f3f4f6;padding:.5rem .75rem}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{overflow:hidden;position:relative;width:100%}.carousel-inner:after{clear:both;content:"";display:block}.carousel-item{backface-visibility:hidden;display:none;float:left;margin-right:-100%;position:relative;transition:transform .6s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transform:none;transition-property:opacity}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{opacity:1;z-index:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{opacity:0;transition:opacity 0s .6s;z-index:0}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{align-items:center;background:none;border:0;bottom:0;color:#fff;display:flex;justify-content:center;opacity:.5;padding:0;position:absolute;text-align:center;top:0;transition:opacity .15s ease;width:15%;z-index:1}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;opacity:.9;outline:0;text-decoration:none}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{background:50%/100% 100% no-repeat;display:inline-block;height:20px;width:20px}.carousel-control-prev-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m5.25 0-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3E%3C/svg%3E")}.carousel-control-next-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m2.75 0-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3E%3C/svg%3E")}.carousel-indicators{bottom:0;display:flex;justify-content:center;left:0;list-style:none;margin-left:15%;margin-right:15%;padding-left:0;position:absolute;right:0;z-index:15}.carousel-indicators li{background-clip:padding-box;background-color:#fff;border-bottom:10px solid transparent;border-top:10px solid transparent;box-sizing:content-box;cursor:pointer;flex:0 1 auto;height:3px;margin-left:3px;margin-right:3px;opacity:.5;text-indent:-999px;transition:opacity .6s ease;width:30px}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{bottom:20px;color:#fff;left:15%;padding-bottom:20px;padding-top:20px;position:absolute;right:15%;text-align:center;z-index:10}@keyframes spinner-border{to{transform:rotate(1turn)}}.spinner-border{animation:spinner-border .75s linear infinite;border:.25em solid;border-radius:50%;border-right:.25em solid transparent;display:inline-block;height:2rem;vertical-align:-.125em;width:2rem}.spinner-border-sm{border-width:.2em;height:1rem;width:1rem}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{animation:spinner-grow .75s linear infinite;background-color:currentcolor;border-radius:50%;display:inline-block;height:2rem;opacity:0;vertical-align:-.125em;width:2rem}.spinner-grow-sm{height:1rem;width:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{animation-duration:1.5s}}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#4040c8!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#3030a5!important}.bg-secondary{background-color:#4b5563!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#353c46!important}.bg-success{background-color:#059669!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#036546!important}.bg-info{background-color:#2563eb!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#134cca!important}.bg-warning{background-color:#d97706!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#a75c05!important}.bg-danger{background-color:#dc2626!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#b21d1d!important}.bg-light{background-color:#f3f4f6!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#d6d9e0!important}.bg-dark{background-color:#1f2937!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#0d1116!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #4b5563!important}.border-top{border-top:1px solid #4b5563!important}.border-right{border-right:1px solid #4b5563!important}.border-bottom{border-bottom:1px solid #4b5563!important}.border-left{border-left:1px solid #4b5563!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#4040c8!important}.border-secondary{border-color:#4b5563!important}.border-success{border-color:#059669!important}.border-info{border-color:#2563eb!important}.border-warning{border-color:#d97706!important}.border-danger{border-color:#dc2626!important}.border-light{border-color:#f3f4f6!important}.border-dark{border-color:#1f2937!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important}.rounded-right,.rounded-top{border-top-right-radius:.25rem!important}.rounded-bottom,.rounded-right{border-bottom-right-radius:.25rem!important}.rounded-bottom,.rounded-left{border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important}.rounded-lg{border-radius:6px!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix:after{clear:both;content:"";display:block}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}@media (min-width:2px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}}@media (min-width:8px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}}@media (min-width:9px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}}@media (min-width:10px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}}.embed-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.embed-responsive:before{content:"";display:block}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{border:0;bottom:0;height:100%;left:0;position:absolute;top:0;width:100%}.embed-responsive-21by9:before{padding-top:42.85714286%}.embed-responsive-16by9:before{padding-top:56.25%}.embed-responsive-4by3:before{padding-top:75%}.embed-responsive-1by1:before{padding-top:100%}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-fill{flex:1 1 auto!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}@media (min-width:2px){.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}}@media (min-width:8px){.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}}@media (min-width:9px){.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}}@media (min-width:10px){.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:2px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:8px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:9px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:10px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.fixed-top{top:0}.fixed-bottom,.fixed-top{left:0;position:fixed;right:0;z-index:1030}.fixed-bottom{bottom:0}@supports (position:sticky){.sticky-top{position:sticky;top:0;z-index:1020}}.sr-only{clip:rect(0,0,0,0);border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;overflow:visible;position:static;white-space:normal;width:auto}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:2px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:8px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:9px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:10px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link:after{background-color:transparent;bottom:0;content:"";left:0;pointer-events:auto;position:absolute;right:0;top:0;z-index:1}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:2px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:8px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:9px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:10px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:600!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#4040c8!important}a.text-primary:focus,a.text-primary:hover{color:#2a2a92!important}.text-secondary{color:#4b5563!important}a.text-secondary:focus,a.text-secondary:hover{color:#2a3037!important}.text-success{color:#059669!important}a.text-success:focus,a.text-success:hover{color:#034c35!important}.text-info{color:#2563eb!important}a.text-info:focus,a.text-info:hover{color:#1043b3!important}.text-warning{color:#d97706!important}a.text-warning:focus,a.text-warning:hover{color:#8f4e04!important}.text-danger{color:#dc2626!important}a.text-danger:focus,a.text-danger:hover{color:#9c1919!important}.text-light{color:#f3f4f6!important}a.text-light:focus,a.text-light:hover{color:#c7ccd5!important}.text-dark{color:#1f2937!important}a.text-dark:focus,a.text-dark:hover{color:#030506!important}.text-body{color:#f3f4f6!important}.text-muted{color:#9ca3af!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:hsla(0,0%,100%,.5)!important}.text-hide{background-color:transparent;border:0;color:transparent;font:0/0 a;text-shadow:none}.text-decoration-none{text-decoration:none!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,:after,:before{box-shadow:none!important;text-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #6b7280}blockquote,img,pre,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3;}.container,body{min-width:9px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #d1d5db!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#374151}.table .thead-dark th{border-color:#374151;color:inherit}}.vjs-tree{color:#bfc7d5!important;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.vjs-tree .vjs-tree__content{border-left:1px dotted hsla(0,0%,80%,.28)!important}.vjs-tree .vjs-tree__node{cursor:pointer}.vjs-tree .vjs-tree__node:hover{color:#20a0ff}.vjs-tree .vjs-checkbox{left:-30px;position:absolute}.vjs-tree .vjs-value__boolean,.vjs-tree .vjs-value__null,.vjs-tree .vjs-value__number{color:#a291f5!important}.vjs-tree .vjs-value__string{color:#c3e88d!important}.vjs-tree .vjs-key{color:#c3cbd3!important}.hljs-addition,.hljs-attr,.hljs-keyword,.hljs-selector-tag{color:#13ce66}.hljs-bullet,.hljs-meta,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c3e88d}.hljs-comment,.hljs-deletion,.hljs-quote{color:#bfcbd9}.hljs-literal,.hljs-number,.hljs-title{color:#a291f5!important}body{padding-bottom:20px}.container{max-width:1440px}html{min-width:1140px}[v-cloak]{display:none}svg.icon{height:1rem;width:1rem}.header{border-bottom:1px solid #374151}.header .logo{color:#e5e7eb;text-decoration:none}.header .logo svg{height:1.7rem;width:1.7rem}.sidebar .nav-item a{border-radius:6px;color:#9ca3af;margin-bottom:4px;padding:.5rem .75rem}.sidebar .nav-item a svg{fill:#6b7280;height:1.25rem;margin-right:15px;width:1.25rem}.sidebar .nav-item a:hover{background-color:#1f2937;color:#d1d5db}.sidebar .nav-item a.active{background-color:#1f2937;color:#818cf8}.sidebar .nav-item a.active svg{fill:#6366f1}.card{border:none;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)}.card .bottom-radius{border-bottom-left-radius:6px;border-bottom-right-radius:6px}.card .card-header{background-color:#374151;border-bottom:none;min-height:60px;padding-bottom:.7rem;padding-top:.7rem}.card .card-header .btn-group .btn{padding:.2rem .5rem}.card .card-header .form-control-with-icon{position:relative}.card .card-header .form-control-with-icon .icon-wrapper{align-items:center;bottom:0;display:flex;justify-content:center;left:.75rem;position:absolute;top:0}.card .card-header .form-control-with-icon .icon-wrapper .icon{fill:#9ca3af}.card .card-header .form-control-with-icon .form-control{border-radius:9999px;font-size:.875rem;padding-left:2.25rem}.card .table td,.card .table th{padding:.75rem 1.25rem}.card .table th{background-color:#1f2937;border-bottom:0;font-size:.875rem;padding:.5rem 1.25rem}.card .table:not(.table-borderless) td{border-top:1px solid #374151}.card .table.penultimate-column-right td:nth-last-child(2),.card .table.penultimate-column-right th:nth-last-child(2){text-align:right}.card .table td.table-fit,.card .table th.table-fit{white-space:nowrap;width:1%}.fill-text-color{fill:#f3f4f6}.fill-danger{fill:#dc2626}.fill-warning{fill:#d97706}.fill-info{fill:#2563eb}.fill-success{fill:#059669}.fill-primary{fill:#4040c8}button:hover .fill-primary{fill:#fff}.btn-outline-primary.active .fill-primary{fill:#111827}.btn-outline-primary:not(:disabled):not(.disabled).active:focus{box-shadow:none!important}.btn-muted{background:#1f2937;color:#9ca3af}.btn-muted:focus,.btn-muted:hover{background:#374151;color:#d1d5db}.btn-muted.active{background:#4040c8;color:#fff}.badge-secondary{background:#d1d5db;color:#374151}.badge-success{background:#10b981;color:#fff}.badge-info{background:#3b82f6;color:#fff}.badge-warning{background:#f59e0b;color:#fff}.badge-danger{background:#ef4444;color:#fff}.control-action svg{fill:#6b7280;height:1.2rem;width:1.2rem}.control-action svg:hover{fill:#818cf8}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin{animation:spin 2s linear infinite}.card .nav-pills{background:#374151}.card .nav-pills .nav-link{border-radius:0;color:#9ca3af;font-size:.9rem;padding:.75rem 1.25rem}.card .nav-pills .nav-link:focus,.card .nav-pills .nav-link:hover{color:#e5e7eb}.card .nav-pills .nav-link.active{background:none;border-bottom:2px solid #a5b4fc;color:#a5b4fc}.list-enter-active:not(.dontanimate){transition:background 1s linear}.list-enter:not(.dontanimate),.list-leave-to:not(.dontanimate){background:#312e81}.code-bg .list-enter:not(.dontanimate),.code-bg .list-leave-to:not(.dontanimate){background:#4b5563}#indexScreen td{vertical-align:middle!important}.card-bg-secondary{background:#1f2937}.code-bg{background:#292d3e}.disabled-watcher{background:#dc2626;color:#fff;padding:.75rem}.copy-to-clipboard{--tw-text-opacity:1;color:rgb(231 232 242/var(--tw-text-opacity));opacity:.7;outline:2px solid transparent;outline-offset:2px;position:absolute;right:0;top:0;z-index:10} + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#4b5563;--gray-dark:#1f2937;--primary:#4040c8;--secondary:#4b5563;--success:#059669;--info:#2563eb;--warning:#d97706;--danger:#dc2626;--light:#f3f4f6;--dark:#1f2937;--breakpoint-xs:0;--breakpoint-sm:2px;--breakpoint-md:8px;--breakpoint-lg:9px;--breakpoint-xl:10px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,:after,:before{box-sizing:border-box}html{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);font-family:sans-serif;line-height:1.15}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{background-color:#111827;color:#f3f4f6;font-family:Figtree,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;margin:0;text-align:left}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;margin-top:0}p{margin-bottom:1rem;margin-top:0}abbr[data-original-title],abbr[title]{border-bottom:0;cursor:help;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit}address,dl,ol,ul{margin-bottom:1rem}dl,ol,ul{margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:600}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{background-color:transparent;color:#818cf8;text-decoration:none}a:hover{color:#a5b4fc;text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}pre{-ms-overflow-style:scrollbar;margin-bottom:1rem;margin-top:0;overflow:auto}figure{margin:0 0 1rem}img{border-style:none}img,svg{vertical-align:middle}svg{overflow:hidden}table{border-collapse:collapse}caption{caption-side:bottom;color:#9ca3af;padding-bottom:.75rem;padding-top:.75rem;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{color:inherit;display:block;font-size:1.5rem;line-height:inherit;margin-bottom:.5rem;max-width:100%;padding:0;white-space:normal;width:100%}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:none;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}output{display:inline-block}summary{cursor:pointer;display:list-item}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-weight:500;line-height:1.2;margin-bottom:.5rem}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem}.display-1,.display-2{font-weight:300;line-height:1.2}.display-2{font-size:5.5rem}.display-3{font-size:4.5rem}.display-3,.display-4{font-weight:300;line-height:1.2}.display-4{font-size:3.5rem}hr{border:0;border-top:1px solid rgba(0,0,0,.1);margin-bottom:1rem;margin-top:1rem}.small,small{font-size:.875em;font-weight:400}.mark,mark{background-color:#fcf8e3;padding:.2em}.list-inline,.list-unstyled{list-style:none;padding-left:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{font-size:1.25rem;margin-bottom:1rem}.blockquote-footer{color:#4b5563;display:block;font-size:.875em}.blockquote-footer:before{content:"— "}.img-fluid,.img-thumbnail{height:auto;max-width:100%}.img-thumbnail{background-color:#111827;border:1px solid #d1d5db;border-radius:.25rem;padding:.25rem}.figure{display:inline-block}.figure-img{line-height:1;margin-bottom:.5rem}.figure-caption{color:#4b5563;font-size:90%}code{word-wrap:break-word;color:#e83e8c;font-size:87.5%}a>code{color:inherit}kbd{background-color:#111827;border-radius:.2rem;color:#fff;font-size:87.5%;padding:.2rem .4rem}kbd kbd{font-size:100%;font-weight:600;padding:0}pre{color:#111827;display:block;font-size:87.5%}pre code{color:inherit;font-size:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{margin-left:auto;margin-right:auto;padding-left:15px;padding-right:15px;width:100%}@media (min-width:2px){.container,.container-sm{max-width:1137px}}@media (min-width:8px){.container,.container-md,.container-sm{max-width:1138px}}@media (min-width:9px){.container,.container-lg,.container-md,.container-sm{max-width:1139px}}@media (min-width:10px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:flex;flex-wrap:wrap;margin-left:-15px;margin-right:-15px}.no-gutters{margin-left:0;margin-right:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-left:0;padding-right:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{padding-left:15px;padding-right:15px;position:relative;width:100%}.col{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-1>*{flex:0 0 100%;max-width:100%}.row-cols-2>*{flex:0 0 50%;max-width:50%}.row-cols-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-4>*{flex:0 0 25%;max-width:25%}.row-cols-5>*{flex:0 0 20%;max-width:20%}.row-cols-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-auto{flex:0 0 auto;max-width:100%;width:auto}.col-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-3{flex:0 0 25%;max-width:25%}.col-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-6{flex:0 0 50%;max-width:50%}.col-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-9{flex:0 0 75%;max-width:75%}.col-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-12{flex:0 0 100%;max-width:100%}.order-first{order:-1}.order-last{order:13}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-9{order:9}.order-10{order:10}.order-11{order:11}.order-12{order:12}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}@media (min-width:2px){.col-sm{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-sm-1>*{flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-auto{flex:0 0 auto;max-width:100%;width:auto}.col-sm-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-sm-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-sm-3{flex:0 0 25%;max-width:25%}.col-sm-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-sm-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-sm-6{flex:0 0 50%;max-width:50%}.col-sm-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-sm-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-sm-9{flex:0 0 75%;max-width:75%}.col-sm-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-sm-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-sm-12{flex:0 0 100%;max-width:100%}.order-sm-first{order:-1}.order-sm-last{order:13}.order-sm-0{order:0}.order-sm-1{order:1}.order-sm-2{order:2}.order-sm-3{order:3}.order-sm-4{order:4}.order-sm-5{order:5}.order-sm-6{order:6}.order-sm-7{order:7}.order-sm-8{order:8}.order-sm-9{order:9}.order-sm-10{order:10}.order-sm-11{order:11}.order-sm-12{order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}}@media (min-width:8px){.col-md{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-md-1>*{flex:0 0 100%;max-width:100%}.row-cols-md-2>*{flex:0 0 50%;max-width:50%}.row-cols-md-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-md-4>*{flex:0 0 25%;max-width:25%}.row-cols-md-5>*{flex:0 0 20%;max-width:20%}.row-cols-md-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-auto{flex:0 0 auto;max-width:100%;width:auto}.col-md-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-md-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-md-3{flex:0 0 25%;max-width:25%}.col-md-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-md-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-md-6{flex:0 0 50%;max-width:50%}.col-md-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-md-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-md-9{flex:0 0 75%;max-width:75%}.col-md-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-md-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-md-12{flex:0 0 100%;max-width:100%}.order-md-first{order:-1}.order-md-last{order:13}.order-md-0{order:0}.order-md-1{order:1}.order-md-2{order:2}.order-md-3{order:3}.order-md-4{order:4}.order-md-5{order:5}.order-md-6{order:6}.order-md-7{order:7}.order-md-8{order:8}.order-md-9{order:9}.order-md-10{order:10}.order-md-11{order:11}.order-md-12{order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}}@media (min-width:9px){.col-lg{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-lg-1>*{flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-auto{flex:0 0 auto;max-width:100%;width:auto}.col-lg-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-lg-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-lg-3{flex:0 0 25%;max-width:25%}.col-lg-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-lg-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-lg-6{flex:0 0 50%;max-width:50%}.col-lg-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-lg-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-lg-9{flex:0 0 75%;max-width:75%}.col-lg-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-lg-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-lg-12{flex:0 0 100%;max-width:100%}.order-lg-first{order:-1}.order-lg-last{order:13}.order-lg-0{order:0}.order-lg-1{order:1}.order-lg-2{order:2}.order-lg-3{order:3}.order-lg-4{order:4}.order-lg-5{order:5}.order-lg-6{order:6}.order-lg-7{order:7}.order-lg-8{order:8}.order-lg-9{order:9}.order-lg-10{order:10}.order-lg-11{order:11}.order-lg-12{order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}}@media (min-width:10px){.col-xl{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-xl-1>*{flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-auto{flex:0 0 auto;max-width:100%;width:auto}.col-xl-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-xl-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-xl-3{flex:0 0 25%;max-width:25%}.col-xl-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-xl-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-xl-6{flex:0 0 50%;max-width:50%}.col-xl-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-xl-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-xl-9{flex:0 0 75%;max-width:75%}.col-xl-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-xl-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-xl-12{flex:0 0 100%;max-width:100%}.order-xl-first{order:-1}.order-xl-last{order:13}.order-xl-0{order:0}.order-xl-1{order:1}.order-xl-2{order:2}.order-xl-3{order:3}.order-xl-4{order:4}.order-xl-5{order:5}.order-xl-6{order:6}.order-xl-7{order:7}.order-xl-8{order:8}.order-xl-9{order:9}.order-xl-10{order:10}.order-xl-11{order:11}.order-xl-12{order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}}.table{color:#f3f4f6;margin-bottom:1rem;width:100%}.table td,.table th{border-top:1px solid #374151;padding:.75rem;vertical-align:top}.table thead th{border-bottom:2px solid #374151;vertical-align:bottom}.table tbody+tbody{border-top:2px solid #374151}.table-sm td,.table-sm th{padding:.3rem}.table-bordered,.table-bordered td,.table-bordered th{border:1px solid #374151}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{background-color:#374151;color:#f3f4f6}.table-primary,.table-primary>td,.table-primary>th{background-color:#cacaf0}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#9c9ce2}.table-hover .table-primary:hover,.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b6b6ea}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#cdcfd3}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#a1a7ae}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#bfc2c7}.table-success,.table-success>td,.table-success>th{background-color:#b9e2d5}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#7dc8b1}.table-hover .table-success:hover,.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#a7dbca}.table-info,.table-info>td,.table-info>th{background-color:#c2d3f9}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#8eaef5}.table-hover .table-info:hover,.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abc2f7}.table-warning,.table-warning>td,.table-warning>th{background-color:#f4d9b9}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ebb87e}.table-hover .table-warning:hover,.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#f1cda3}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c2c2}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed8e8e}.table-hover .table-danger:hover,.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1acac}.table-light,.table-light>td,.table-light>th{background-color:#fcfcfc}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#f9f9fa}.table-hover .table-light:hover,.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#efefef}.table-dark,.table-dark>td,.table-dark>th{background-color:#c0c3c7}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#8b9097}.table-hover .table-dark:hover,.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b3b6bb}.table-active,.table-active>td,.table-active>th{background-color:#374151}.table-hover .table-active:hover,.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:#2d3542}.table .thead-dark th{background-color:#1f2937;border-color:#2d3b4f;color:#fff}.table .thead-light th{background-color:#e5e7eb;border-color:#374151;color:#374151}.table-dark{background-color:#1f2937;color:#fff}.table-dark td,.table-dark th,.table-dark thead th{border-color:#2d3b4f}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:hsla(0,0%,100%,.05)}.table-dark.table-hover tbody tr:hover{background-color:hsla(0,0%,100%,.075);color:#fff}@media (max-width:1.98px){.table-responsive-sm{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:7.98px){.table-responsive-md{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-md>.table-bordered{border:0}}@media (max-width:8.98px){.table-responsive-lg{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:9.98px){.table-responsive-xl{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive>.table-bordered{border:0}.form-control{background-clip:padding-box;background-color:#1f2937;border:1px solid #4b5563;border-radius:.25rem;color:#e5e7eb;display:block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem .75rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{background-color:#1f2937;border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);color:#e5e7eb;outline:0}.form-control::-moz-placeholder{color:#4b5563;opacity:1}.form-control::placeholder{color:#4b5563;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e5e7eb;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #e5e7eb}select.form-control:focus::-ms-value{background-color:#1f2937;color:#e5e7eb}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{font-size:inherit;line-height:1.5;margin-bottom:0;padding-bottom:calc(.375rem + 1px);padding-top:calc(.375rem + 1px)}.col-form-label-lg{font-size:1.25rem;line-height:1.5;padding-bottom:calc(.5rem + 1px);padding-top:calc(.5rem + 1px)}.col-form-label-sm{font-size:.875rem;line-height:1.5;padding-bottom:calc(.25rem + 1px);padding-top:calc(.25rem + 1px)}.form-control-plaintext{background-color:transparent;border:solid transparent;border-width:1px 0;color:#f3f4f6;display:block;font-size:1rem;line-height:1.5;margin-bottom:0;padding:.375rem 0;width:100%}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-left:0;padding-right:0}.form-control-sm{border-radius:.2rem;font-size:.875rem;height:calc(1.5em + .5rem + 2px);line-height:1.5;padding:.25rem .5rem}.form-control-lg{border-radius:6px;font-size:1.25rem;height:calc(1.5em + 1rem + 2px);line-height:1.5;padding:.5rem 1rem}select.form-control[multiple],select.form-control[size],textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:flex;flex-wrap:wrap;margin-left:-5px;margin-right:-5px}.form-row>.col,.form-row>[class*=col-]{padding-left:5px;padding-right:5px}.form-check{display:block;padding-left:1.25rem;position:relative}.form-check-input{margin-left:-1.25rem;margin-top:.3rem;position:absolute}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#9ca3af}.form-check-label{margin-bottom:0}.form-check-inline{align-items:center;display:inline-flex;margin-right:.75rem;padding-left:0}.form-check-inline .form-check-input{margin-left:0;margin-right:.3125rem;margin-top:0;position:static}.valid-feedback{color:#059669;display:none;font-size:.875em;margin-top:.25rem;width:100%}.valid-tooltip{background-color:rgba(5,150,105,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.valid-tooltip,.form-row>[class*=col-]>.valid-tooltip{left:5px}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#059669;padding-right:calc(1.5em + .75rem)!important}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.was-validated select.form-control:valid,select.form-control.is-valid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-valid,.was-validated .custom-select:valid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#1f2937 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#059669;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#059669}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#059669}.custom-control-input.is-valid~.custom-control-label:before,.was-validated .custom-control-input:valid~.custom-control-label:before{border-color:#059669}.custom-control-input.is-valid:checked~.custom-control-label:before,.was-validated .custom-control-input:valid:checked~.custom-control-label:before{background-color:#07c78c;border-color:#07c78c}.custom-control-input.is-valid:focus~.custom-control-label:before,.was-validated .custom-control-input:valid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label:before{border-color:#059669}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#059669}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.invalid-feedback{color:#dc2626;display:none;font-size:.875em;margin-top:.25rem;width:100%}.invalid-tooltip{background-color:rgba(220,38,38,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.invalid-tooltip,.form-row>[class*=col-]>.invalid-tooltip{left:5px}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#dc2626;padding-right:calc(1.5em + .75rem)!important}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.was-validated select.form-control:invalid,select.form-control.is-invalid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#1f2937 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#dc2626;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc2626}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc2626}.custom-control-input.is-invalid~.custom-control-label:before,.was-validated .custom-control-input:invalid~.custom-control-label:before{border-color:#dc2626}.custom-control-input.is-invalid:checked~.custom-control-label:before,.was-validated .custom-control-input:invalid:checked~.custom-control-label:before{background-color:#e35252;border-color:#e35252}.custom-control-input.is-invalid:focus~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label:before{border-color:#dc2626}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc2626}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-inline{align-items:center;display:flex;flex-flow:row wrap}.form-inline .form-check{width:100%}@media (min-width:2px){.form-inline label{justify-content:center}.form-inline .form-group,.form-inline label{align-items:center;display:flex;margin-bottom:0}.form-inline .form-group{flex:0 0 auto;flex-flow:row wrap}.form-inline .form-control{display:inline-block;vertical-align:middle;width:auto}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{align-items:center;display:flex;justify-content:center;padding-left:0;width:auto}.form-inline .form-check-input{flex-shrink:0;margin-left:0;margin-right:.25rem;margin-top:0;position:relative}.form-inline .custom-control{align-items:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{background-color:transparent;border:1px solid transparent;border-radius:.25rem;color:#f3f4f6;display:inline-block;font-size:1rem;font-weight:400;line-height:1.5;padding:.375rem .75rem;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#f3f4f6;text-decoration:none}.btn.focus,.btn:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary.focus,.btn-primary:focus,.btn-primary:hover{background-color:#3232af;border-color:#3030a5;color:#fff}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 .2rem rgba(93,93,208,.5)}.btn-primary.disabled,.btn-primary:disabled{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{background-color:#3030a5;border-color:#2d2d9b;color:#fff}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(93,93,208,.5)}.btn-secondary{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary.focus,.btn-secondary:focus,.btn-secondary:hover{background-color:#3b424d;border-color:#353c46;color:#fff}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 .2rem hsla(213,9%,44%,.5)}.btn-secondary.disabled,.btn-secondary:disabled{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{background-color:#353c46;border-color:#30363f;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem hsla(213,9%,44%,.5)}.btn-success{background-color:#059669;border-color:#059669;color:#fff}.btn-success.focus,.btn-success:focus,.btn-success:hover{background-color:#04714f;border-color:#036546;color:#fff}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 .2rem rgba(43,166,128,.5)}.btn-success.disabled,.btn-success:disabled{background-color:#059669;border-color:#059669;color:#fff}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{background-color:#036546;border-color:#03583e;color:#fff}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(43,166,128,.5)}.btn-info{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info.focus,.btn-info:focus,.btn-info:hover{background-color:#1451d6;border-color:#134cca;color:#fff}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 .2rem rgba(70,122,238,.5)}.btn-info.disabled,.btn-info:disabled{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{background-color:#134cca;border-color:#1248bf;color:#fff}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(70,122,238,.5)}.btn-warning{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning.focus,.btn-warning:focus,.btn-warning:hover{background-color:#b46305;border-color:#a75c05;color:#fff}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 .2rem rgba(223,139,43,.5)}.btn-warning.disabled,.btn-warning:disabled{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{background-color:#a75c05;border-color:#9b5504;color:#fff}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(223,139,43,.5)}.btn-danger{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger.focus,.btn-danger:focus,.btn-danger:hover{background-color:#bd1f1f;border-color:#b21d1d;color:#fff}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 .2rem rgba(225,71,71,.5)}.btn-danger.disabled,.btn-danger:disabled{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{background-color:#b21d1d;border-color:#a71b1b;color:#fff}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,71,71,.5)}.btn-light{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light.focus,.btn-light:focus,.btn-light:hover{background-color:#dde0e6;border-color:#d6d9e0;color:#111827}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 .2rem hsla(220,7%,83%,.5)}.btn-light.disabled,.btn-light:disabled{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{background-color:#d6d9e0;border-color:#cfd3db;color:#111827}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem hsla(220,7%,83%,.5)}.btn-dark{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark.focus,.btn-dark:focus,.btn-dark:hover{background-color:#11171f;border-color:#0d1116;color:#fff}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 .2rem rgba(65,73,85,.5)}.btn-dark.disabled,.btn-dark:disabled{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{background-color:#0d1116;border-color:#080b0e;color:#fff}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(65,73,85,.5)}.btn-outline-primary{border-color:#4040c8;color:#4040c8}.btn-outline-primary:hover{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{background-color:transparent;color:#4040c8}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5)}.btn-outline-secondary{border-color:#4b5563;color:#4b5563}.btn-outline-secondary:hover{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{background-color:transparent;color:#4b5563}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5)}.btn-outline-success{border-color:#059669;color:#059669}.btn-outline-success:hover{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{background-color:transparent;color:#059669}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5)}.btn-outline-info{border-color:#2563eb;color:#2563eb}.btn-outline-info:hover{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{background-color:transparent;color:#2563eb}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5)}.btn-outline-warning{border-color:#d97706;color:#d97706}.btn-outline-warning:hover{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{background-color:transparent;color:#d97706}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5)}.btn-outline-danger{border-color:#dc2626;color:#dc2626}.btn-outline-danger:hover{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{background-color:transparent;color:#dc2626}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5)}.btn-outline-light{border-color:#f3f4f6;color:#f3f4f6}.btn-outline-light:hover{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{background-color:transparent;color:#f3f4f6}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5)}.btn-outline-dark{border-color:#1f2937;color:#1f2937}.btn-outline-dark:hover{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{background-color:transparent;color:#1f2937}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5)}.btn-link{color:#818cf8;font-weight:400;text-decoration:none}.btn-link:hover{color:#a5b4fc}.btn-link.focus,.btn-link:focus,.btn-link:hover{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#4b5563;pointer-events:none}.btn-group-lg>.btn,.btn-lg{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.btn-group-sm>.btn,.btn-sm{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;position:relative;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.width{height:auto;transition:width .35s ease;width:0}@media (prefers-reduced-motion:reduce){.collapsing.width{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle:after{border-bottom:0;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropdown-toggle:empty:after{margin-left:0}.dropdown-menu{background-clip:padding-box;background-color:#374151;border:1px solid rgba(0,0,0,.15);border-radius:.25rem;color:#f3f4f6;display:none;float:left;font-size:1rem;left:0;list-style:none;margin:.125rem 0 0;min-width:10rem;padding:.5rem 0;position:absolute;text-align:left;top:100%;z-index:1000}.dropdown-menu-left{left:0;right:auto}.dropdown-menu-right{left:auto;right:0}@media (min-width:2px){.dropdown-menu-sm-left{left:0;right:auto}.dropdown-menu-sm-right{left:auto;right:0}}@media (min-width:8px){.dropdown-menu-md-left{left:0;right:auto}.dropdown-menu-md-right{left:auto;right:0}}@media (min-width:9px){.dropdown-menu-lg-left{left:0;right:auto}.dropdown-menu-lg-right{left:auto;right:0}}@media (min-width:10px){.dropdown-menu-xl-left{left:0;right:auto}.dropdown-menu-xl-right{left:auto;right:0}}.dropup .dropdown-menu{bottom:100%;margin-bottom:.125rem;margin-top:0;top:auto}.dropup .dropdown-toggle:after{border-bottom:.3em solid;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:0;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropup .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-menu{left:100%;margin-left:.125rem;margin-top:0;right:auto;top:0}.dropright .dropdown-toggle:after{border-bottom:.3em solid transparent;border-left:.3em solid;border-right:0;border-top:.3em solid transparent;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropright .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-toggle:after{vertical-align:0}.dropleft .dropdown-menu{left:auto;margin-right:.125rem;margin-top:0;right:100%;top:0}.dropleft .dropdown-toggle:after{content:"";display:inline-block;display:none;margin-left:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:before{border-bottom:.3em solid transparent;border-right:.3em solid;border-top:.3em solid transparent;content:"";display:inline-block;margin-right:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:empty:after{margin-left:0}.dropleft .dropdown-toggle:before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{bottom:auto;right:auto}.dropdown-divider{border-top:1px solid #e5e7eb;height:0;margin:.5rem 0;overflow:hidden}.dropdown-item{background-color:transparent;border:0;clear:both;color:#fff;display:block;font-weight:400;padding:.25rem 1.5rem;text-align:inherit;white-space:nowrap;width:100%}.dropdown-item:focus,.dropdown-item:hover{background-color:#e5e7eb;color:#090d15;text-decoration:none}.dropdown-item.active,.dropdown-item:active{background-color:#4040c8;color:#fff;text-decoration:none}.dropdown-item.disabled,.dropdown-item:disabled{background-color:transparent;color:#6b7280;pointer-events:none}.dropdown-menu.show{display:block}.dropdown-header{color:#4b5563;display:block;font-size:.875rem;margin-bottom:0;padding:.5rem 1.5rem;white-space:nowrap}.dropdown-item-text{color:#fff;display:block;padding:.25rem 1.5rem}.btn-group,.btn-group-vertical{display:inline-flex;position:relative;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{flex:1 1 auto;position:relative}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.dropdown-toggle-split{padding-left:.5625rem;padding-right:.5625rem}.dropdown-toggle-split:after,.dropright .dropdown-toggle-split:after,.dropup .dropdown-toggle-split:after{margin-left:0}.dropleft .dropdown-toggle-split:before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-left:.375rem;padding-right:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-left:.75rem;padding-right:.75rem}.btn-group-vertical{align-items:flex-start;flex-direction:column;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-left-radius:0;border-bottom-right-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{clip:rect(0,0,0,0);pointer-events:none;position:absolute}.input-group{align-items:stretch;display:flex;flex-wrap:wrap;position:relative;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{flex:1 1 auto;margin-bottom:0;min-width:0;position:relative;width:1%}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.input-group>.custom-file{align-items:center;display:flex}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label:after{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-bottom-left-radius:0;border-top-left-radius:0}.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label,.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label:after,.input-group.has-validation>.custom-select:nth-last-child(n+3),.input-group.has-validation>.form-control:nth-last-child(n+3),.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label,.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label:after,.input-group:not(.has-validation)>.custom-select:not(:last-child),.input-group:not(.has-validation)>.form-control:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group-append,.input-group-prepend{display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{align-items:center;background-color:#e5e7eb;border:1px solid #4b5563;border-radius:.25rem;color:#e5e7eb;display:flex;font-size:1rem;font-weight:400;line-height:1.5;margin-bottom:0;padding:.375rem .75rem;text-align:center;white-space:nowrap}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.btn,.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.input-group-text,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.btn,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-bottom-left-radius:0;border-top-left-radius:0}.custom-control{display:block;min-height:1.5rem;padding-left:1.5rem;position:relative;-webkit-print-color-adjust:exact;print-color-adjust:exact;z-index:1}.custom-control-inline{display:inline-flex;margin-right:1rem}.custom-control-input{height:1.25rem;left:0;opacity:0;position:absolute;width:1rem;z-index:-1}.custom-control-input:checked~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8;color:#fff}.custom-control-input:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label:before{border-color:#a3a3e5}.custom-control-input:not(:disabled):active~.custom-control-label:before{background-color:#cbcbf0;border-color:#cbcbf0;color:#fff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#4b5563}.custom-control-input:disabled~.custom-control-label:before,.custom-control-input[disabled]~.custom-control-label:before{background-color:#e5e7eb}.custom-control-label{margin-bottom:0;position:relative;vertical-align:top}.custom-control-label:before{background-color:#1f2937;border:1px solid #6b7280;pointer-events:none}.custom-control-label:after,.custom-control-label:before{content:"";display:block;height:1rem;left:-1.5rem;position:absolute;top:.25rem;width:1rem}.custom-control-label:after{background:50%/50% 50% no-repeat}.custom-checkbox .custom-control-label:before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23fff' d='m6.564.75-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-radio .custom-control-label:before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label:before{border-radius:.5rem;left:-2.25rem;pointer-events:all;width:1.75rem}.custom-switch .custom-control-label:after{background-color:#6b7280;border-radius:.5rem;height:calc(1rem - 4px);left:calc(-2.25rem + 2px);top:calc(.25rem + 2px);transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:calc(1rem - 4px)}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label:after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label:after{background-color:#1f2937;transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#1f2937 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat;border:1px solid #4b5563;border-radius:.25rem;color:#e5e7eb;display:inline-block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem 1.75rem .375rem .75rem;vertical-align:middle;width:100%}.custom-select:focus{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.custom-select:focus::-ms-value{background-color:#1f2937;color:#e5e7eb}.custom-select[multiple],.custom-select[size]:not([size="1"]){background-image:none;height:auto;padding-right:.75rem}.custom-select:disabled{background-color:#e5e7eb;color:#4b5563}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #e5e7eb}.custom-select-sm{font-size:.875rem;height:calc(1.5em + .5rem + 2px);padding-bottom:.25rem;padding-left:.5rem;padding-top:.25rem}.custom-select-lg{font-size:1.25rem;height:calc(1.5em + 1rem + 2px);padding-bottom:.5rem;padding-left:1rem;padding-top:.5rem}.custom-file{display:inline-block;margin-bottom:0}.custom-file,.custom-file-input{height:calc(1.5em + .75rem + 2px);position:relative;width:100%}.custom-file-input{margin:0;opacity:0;overflow:hidden;z-index:2}.custom-file-input:focus~.custom-file-label{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e5e7eb}.custom-file-input:lang(en)~.custom-file-label:after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]:after{content:attr(data-browse)}.custom-file-label{background-color:#1f2937;border:1px solid #4b5563;border-radius:.25rem;font-weight:400;height:calc(1.5em + .75rem + 2px);left:0;overflow:hidden;z-index:1}.custom-file-label,.custom-file-label:after{color:#e5e7eb;line-height:1.5;padding:.375rem .75rem;position:absolute;right:0;top:0}.custom-file-label:after{background-color:#e5e7eb;border-left:inherit;border-radius:0 .25rem .25rem 0;bottom:0;content:"Browse";display:block;height:calc(1.5em + .75rem);z-index:3}.custom-range{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;height:1.4rem;padding:0;width:100%}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #111827,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #111827,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #111827,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-top:-.25rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#cbcbf0}.custom-range::-webkit-slider-runnable-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-moz-range-thumb{-moz-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#cbcbf0}.custom-range::-moz-range-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-thumb{appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-left:.2rem;margin-right:.2rem;margin-top:0;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#cbcbf0}.custom-range::-ms-track{background-color:transparent;border-color:transparent;border-width:.5rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-fill-lower,.custom-range::-ms-fill-upper{background-color:#d1d5db;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px}.custom-range:disabled::-webkit-slider-thumb{background-color:#6b7280}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#6b7280}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#6b7280}.custom-control-label:before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label:before,.custom-file-label,.custom-select{transition:none}}.nav{display:flex;flex-wrap:wrap;list-style:none;margin-bottom:0;padding-left:0}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#4b5563;cursor:default;pointer-events:none}.nav-tabs{border-bottom:1px solid #d1d5db}.nav-tabs .nav-link{background-color:transparent;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem;margin-bottom:-1px}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e5e7eb #e5e7eb #d1d5db;isolation:isolate}.nav-tabs .nav-link.disabled{background-color:transparent;border-color:transparent;color:#4b5563}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{background-color:#111827;border-color:#d1d5db #d1d5db #111827;color:#374151}.nav-tabs .dropdown-menu{border-top-left-radius:0;border-top-right-radius:0;margin-top:-1px}.nav-pills .nav-link{background:none;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{background-color:#1f2937;color:#fff}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{padding:.5rem 1rem;position:relative}.navbar,.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{align-items:center;display:flex;flex-wrap:wrap;justify-content:space-between}.navbar-brand{display:inline-block;font-size:1.25rem;line-height:inherit;margin-right:1rem;padding-bottom:.3125rem;padding-top:.3125rem;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:flex;flex-direction:column;list-style:none;margin-bottom:0;padding-left:0}.navbar-nav .nav-link{padding-left:0;padding-right:0}.navbar-nav .dropdown-menu{float:none;position:static}.navbar-text{display:inline-block;padding-bottom:.5rem;padding-top:.5rem}.navbar-collapse{align-items:center;flex-basis:100%;flex-grow:1}.navbar-toggler{background-color:transparent;border:1px solid transparent;border-radius:.25rem;font-size:1.25rem;line-height:1;padding:.25rem .75rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{background:50%/100% 100% no-repeat;content:"";display:inline-block;height:1.5em;vertical-align:middle;width:1.5em}.navbar-nav-scroll{max-height:75vh;overflow-y:auto}@media (max-width:1.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-left:0;padding-right:0}}@media (min-width:2px){.navbar-expand-sm{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{flex-wrap:nowrap}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:7.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-left:0;padding-right:0}}@media (min-width:8px){.navbar-expand-md{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{flex-wrap:nowrap}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:8.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-left:0;padding-right:0}}@media (min-width:9px){.navbar-expand-lg{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{flex-wrap:nowrap}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:9.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-left:0;padding-right:0}}@media (min-width:10px){.navbar-expand-xl{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{flex-wrap:nowrap}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-left:0;padding-right:0}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{flex-wrap:nowrap}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand,.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{border-color:rgba(0,0,0,.1);color:rgba(0,0,0,.5)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand,.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:hsla(0,0%,100%,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:hsla(0,0%,100%,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{border-color:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-dark .navbar-text{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{word-wrap:break-word;background-clip:border-box;background-color:#1f2937;border:1px solid rgba(0,0,0,.125);border-radius:6px;display:flex;flex-direction:column;min-width:0;position:relative}.card>hr{margin-left:0;margin-right:0}.card>.list-group{border-bottom:inherit;border-top:inherit}.card>.list-group:first-child{border-top-left-radius:5px;border-top-right-radius:5px;border-top-width:0}.card>.list-group:last-child{border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-bottom-width:0}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{background-color:#374151;border-bottom:1px solid rgba(0,0,0,.125);margin-bottom:0;padding:.75rem 1.25rem}.card-header:first-child{border-radius:5px 5px 0 0}.card-footer{background-color:#374151;border-top:1px solid rgba(0,0,0,.125);padding:.75rem 1.25rem}.card-footer:last-child{border-radius:0 0 5px 5px}.card-header-tabs{border-bottom:0;margin-bottom:-.75rem}.card-header-pills,.card-header-tabs{margin-left:-.625rem;margin-right:-.625rem}.card-img-overlay{border-radius:5px;bottom:0;left:0;padding:1.25rem;position:absolute;right:0;top:0}.card-img,.card-img-bottom,.card-img-top{flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:5px;border-top-right-radius:5px}.card-img,.card-img-bottom{border-bottom-left-radius:5px;border-bottom-right-radius:5px}.card-deck .card{margin-bottom:15px}@media (min-width:2px){.card-deck{display:flex;flex-flow:row wrap;margin-left:-15px;margin-right:-15px}.card-deck .card{flex:1 0 0%;margin-bottom:0;margin-left:15px;margin-right:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:2px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{border-left:0;margin-left:0}.card-group>.card:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:2px){.card-columns{-moz-column-count:3;column-count:3;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{background-color:#e5e7eb;border-radius:.25rem;display:flex;flex-wrap:wrap;list-style:none;margin-bottom:1rem;padding:.75rem 1rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item:before{color:#4b5563;content:"/";float:left;padding-right:.5rem}.breadcrumb-item+.breadcrumb-item:hover:before{text-decoration:underline;text-decoration:none}.breadcrumb-item.active{color:#4b5563}.pagination{border-radius:.25rem;display:flex;list-style:none;padding-left:0}.page-link{background-color:#fff;border:1px solid #d1d5db;color:#818cf8;display:block;line-height:1.25;margin-left:-1px;padding:.5rem .75rem;position:relative}.page-link:hover{background-color:#e5e7eb;border-color:#d1d5db;color:#a5b4fc;text-decoration:none;z-index:2}.page-link:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0;z-index:3}.page-item:first-child .page-link{border-bottom-left-radius:.25rem;border-top-left-radius:.25rem;margin-left:0}.page-item:last-child .page-link{border-bottom-right-radius:.25rem;border-top-right-radius:.25rem}.page-item.active .page-link{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:3}.page-item.disabled .page-link{background-color:#fff;border-color:#d1d5db;color:#4b5563;cursor:auto;pointer-events:none}.pagination-lg .page-link{font-size:1.25rem;line-height:1.5;padding:.75rem 1.5rem}.pagination-lg .page-item:first-child .page-link{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg .page-item:last-child .page-link{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm .page-link{font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.pagination-sm .page-item:first-child .page-link{border-bottom-left-radius:.2rem;border-top-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-bottom-right-radius:.2rem;border-top-right-radius:.2rem}.badge{border-radius:.25rem;display:inline-block;font-size:.875rem;font-weight:600;line-height:1;padding:.25em .4em;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;vertical-align:baseline;white-space:nowrap}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{border-radius:10rem;padding-left:.6em;padding-right:.6em}.badge-primary{background-color:#4040c8;color:#fff}a.badge-primary:focus,a.badge-primary:hover{background-color:#3030a5;color:#fff}a.badge-primary.focus,a.badge-primary:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5);outline:0}.badge-secondary{background-color:#4b5563;color:#fff}a.badge-secondary:focus,a.badge-secondary:hover{background-color:#353c46;color:#fff}a.badge-secondary.focus,a.badge-secondary:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5);outline:0}.badge-success{background-color:#059669}a.badge-success:focus,a.badge-success:hover{background-color:#036546;color:#fff}a.badge-success.focus,a.badge-success:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5);outline:0}.badge-info{background-color:#2563eb}a.badge-info:focus,a.badge-info:hover{background-color:#134cca;color:#fff}a.badge-info.focus,a.badge-info:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5);outline:0}.badge-warning{background-color:#d97706}a.badge-warning:focus,a.badge-warning:hover{background-color:#a75c05;color:#fff}a.badge-warning.focus,a.badge-warning:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5);outline:0}.badge-danger{background-color:#dc2626}a.badge-danger:focus,a.badge-danger:hover{background-color:#b21d1d;color:#fff}a.badge-danger.focus,a.badge-danger:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5);outline:0}.badge-light{background-color:#f3f4f6;color:#111827}a.badge-light:focus,a.badge-light:hover{background-color:#d6d9e0;color:#111827}a.badge-light.focus,a.badge-light:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5);outline:0}.badge-dark{background-color:#1f2937;color:#fff}a.badge-dark:focus,a.badge-dark:hover{background-color:#0d1116;color:#fff}a.badge-dark.focus,a.badge-dark:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5);outline:0}.jumbotron{background-color:#e5e7eb;border-radius:6px;margin-bottom:2rem;padding:2rem 1rem}@media (min-width:2px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{border-radius:0;padding-left:0;padding-right:0}.alert{border:1px solid transparent;border-radius:.25rem;margin-bottom:1rem;padding:.75rem 1.25rem;position:relative}.alert-heading{color:inherit}.alert-link{font-weight:600}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{color:inherit;padding:.75rem 1.25rem;position:absolute;right:0;top:0;z-index:2}.alert-primary{background-color:#d9d9f4;border-color:#cacaf0;color:#212168}.alert-primary hr{border-top-color:#b6b6ea}.alert-primary .alert-link{color:#151541}.alert-secondary{background-color:#dbdde0;border-color:#cdcfd3;color:#272c33}.alert-secondary hr{border-top-color:#bfc2c7}.alert-secondary .alert-link{color:#111316}.alert-success{background-color:#cdeae1;border-color:#b9e2d5;color:#034e37}.alert-success hr{border-top-color:#a7dbca}.alert-success .alert-link{color:#011d14}.alert-info{background-color:#d3e0fb;border-color:#c2d3f9;color:#13337a}.alert-info hr{border-top-color:#abc2f7}.alert-info .alert-link{color:#0c214e}.alert-warning{background-color:#f7e4cd;border-color:#f4d9b9;color:#713e03}.alert-warning hr{border-top-color:#f1cda3}.alert-warning .alert-link{color:#3f2302}.alert-danger{background-color:#f8d4d4;border-color:#f5c2c2;color:#721414}.alert-danger hr{border-top-color:#f1acac}.alert-danger .alert-link{color:#470c0c}.alert-light{background-color:#fdfdfd;border-color:#fcfcfc;color:#7e7f80}.alert-light hr{border-top-color:#efefef}.alert-light .alert-link{color:#656666}.alert-dark{background-color:#d2d4d7;border-color:#c0c3c7;color:#10151d}.alert-dark hr{border-top-color:#b3b6bb}.alert-dark .alert-link{color:#000}@keyframes progress-bar-stripes{0%{background-position:1rem 0}to{background-position:0 0}}.progress{background-color:#e5e7eb;border-radius:.25rem;font-size:.75rem;height:1rem;line-height:0}.progress,.progress-bar{display:flex;overflow:hidden}.progress-bar{background-color:#4040c8;color:#fff;flex-direction:column;justify-content:center;text-align:center;transition:width .6s ease;white-space:nowrap}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent);background-size:1rem 1rem}.progress-bar-animated{animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.media{align-items:flex-start;display:flex}.media-body{flex:1}.list-group{border-radius:.25rem;display:flex;flex-direction:column;margin-bottom:0;padding-left:0}.list-group-item-action{color:#374151;text-align:inherit;width:100%}.list-group-item-action:focus,.list-group-item-action:hover{background-color:#f3f4f6;color:#374151;text-decoration:none;z-index:1}.list-group-item-action:active{background-color:#e5e7eb;color:#f3f4f6}.list-group-item{background-color:#fff;border:1px solid rgba(0,0,0,.125);display:block;padding:.75rem 1.25rem;position:relative}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{background-color:#fff;color:#4b5563;pointer-events:none}.list-group-item.active{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:2}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{border-top-width:1px;margin-top:-1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}@media (min-width:2px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:8px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-md>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:9px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:10px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{background-color:#cacaf0;color:#212168}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{background-color:#b6b6ea;color:#212168}.list-group-item-primary.list-group-item-action.active{background-color:#212168;border-color:#212168;color:#fff}.list-group-item-secondary{background-color:#cdcfd3;color:#272c33}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{background-color:#bfc2c7;color:#272c33}.list-group-item-secondary.list-group-item-action.active{background-color:#272c33;border-color:#272c33;color:#fff}.list-group-item-success{background-color:#b9e2d5;color:#034e37}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{background-color:#a7dbca;color:#034e37}.list-group-item-success.list-group-item-action.active{background-color:#034e37;border-color:#034e37;color:#fff}.list-group-item-info{background-color:#c2d3f9;color:#13337a}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{background-color:#abc2f7;color:#13337a}.list-group-item-info.list-group-item-action.active{background-color:#13337a;border-color:#13337a;color:#fff}.list-group-item-warning{background-color:#f4d9b9;color:#713e03}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{background-color:#f1cda3;color:#713e03}.list-group-item-warning.list-group-item-action.active{background-color:#713e03;border-color:#713e03;color:#fff}.list-group-item-danger{background-color:#f5c2c2;color:#721414}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{background-color:#f1acac;color:#721414}.list-group-item-danger.list-group-item-action.active{background-color:#721414;border-color:#721414;color:#fff}.list-group-item-light{background-color:#fcfcfc;color:#7e7f80}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{background-color:#efefef;color:#7e7f80}.list-group-item-light.list-group-item-action.active{background-color:#7e7f80;border-color:#7e7f80;color:#fff}.list-group-item-dark{background-color:#c0c3c7;color:#10151d}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{background-color:#b3b6bb;color:#10151d}.list-group-item-dark.list-group-item-action.active{background-color:#10151d;border-color:#10151d;color:#fff}.close{color:#000;float:right;font-size:1.5rem;font-weight:600;line-height:1;opacity:.5;text-shadow:0 1px 0 #fff}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{background-color:transparent;border:0;padding:0}a.close.disabled{pointer-events:none}.toast{background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border:1px solid rgba(0,0,0,.1);border-radius:.25rem;box-shadow:0 .25rem .75rem rgba(0,0,0,.1);flex-basis:350px;font-size:.875rem;max-width:350px;opacity:0}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{align-items:center;background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px);color:#4b5563;display:flex;padding:.25rem .75rem}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{display:none;height:100%;left:0;outline:0;overflow:hidden;position:fixed;top:0;width:100%;z-index:1050}.modal-dialog{margin:.5rem;pointer-events:none;position:relative;width:auto}.modal.fade .modal-dialog{transform:translateY(-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{align-items:center;display:flex;min-height:calc(100% - 1rem)}.modal-dialog-centered:before{content:"";display:block;height:calc(100vh - 1rem);height:-moz-min-content;height:min-content}.modal-dialog-centered.modal-dialog-scrollable{flex-direction:column;height:100%;justify-content:center}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable:before{content:none}.modal-content{background-clip:padding-box;background-color:#1f2937;border:1px solid rgba(0,0,0,.2);border-radius:6px;display:flex;flex-direction:column;outline:0;pointer-events:auto;position:relative;width:100%}.modal-backdrop{background-color:#4b5563;height:100vh;left:0;position:fixed;top:0;width:100vw;z-index:1040}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{align-items:flex-start;border-bottom:1px solid #4b5563;border-top-left-radius:5px;border-top-right-radius:5px;display:flex;justify-content:space-between;padding:1rem}.modal-header .close{margin:-1rem -1rem -1rem auto;padding:1rem}.modal-title{line-height:1.5;margin-bottom:0}.modal-body{flex:1 1 auto;padding:1rem;position:relative}.modal-footer{align-items:center;border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-top:1px solid #4b5563;display:flex;flex-wrap:wrap;justify-content:flex-end;padding:.75rem}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{height:50px;overflow:scroll;position:absolute;top:-9999px;width:50px}@media (min-width:2px){.modal-dialog{margin:1.75rem auto;max-width:500px}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered:before{height:calc(100vh - 3.5rem);height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:9px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:10px){.modal-xl{max-width:1140px}}.tooltip{word-wrap:break-word;display:block;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.5;margin:0;opacity:0;position:absolute;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;z-index:1070}.tooltip.show{opacity:.9}.tooltip .arrow{display:block;height:.4rem;position:absolute;width:.8rem}.tooltip .arrow:before{border-color:transparent;border-style:solid;content:"";position:absolute}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow:before,.bs-tooltip-top .arrow:before{border-top-color:#000;border-width:.4rem .4rem 0;top:0}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{height:.8rem;left:0;width:.4rem}.bs-tooltip-auto[x-placement^=right] .arrow:before,.bs-tooltip-right .arrow:before{border-right-color:#000;border-width:.4rem .4rem .4rem 0;right:0}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow:before,.bs-tooltip-bottom .arrow:before{border-bottom-color:#000;border-width:0 .4rem .4rem;bottom:0}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{height:.8rem;right:0;width:.4rem}.bs-tooltip-auto[x-placement^=left] .arrow:before,.bs-tooltip-left .arrow:before{border-left-color:#000;border-width:.4rem 0 .4rem .4rem;left:0}.tooltip-inner{background-color:#000;border-radius:.25rem;color:#fff;max-width:200px;padding:.25rem .5rem;text-align:center}.popover{word-wrap:break-word;background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.2);border-radius:6px;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;left:0;letter-spacing:normal;line-break:auto;line-height:1.5;max-width:276px;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;top:0;white-space:normal;word-break:normal;word-spacing:normal;z-index:1060}.popover,.popover .arrow{display:block;position:absolute}.popover .arrow{height:.5rem;margin:0 6px;width:1rem}.popover .arrow:after,.popover .arrow:before{border-color:transparent;border-style:solid;content:"";display:block;position:absolute}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow:before,.bs-popover-top>.arrow:before{border-top-color:rgba(0,0,0,.25);border-width:.5rem .5rem 0;bottom:0}.bs-popover-auto[x-placement^=top]>.arrow:after,.bs-popover-top>.arrow:after{border-top-color:#fff;border-width:.5rem .5rem 0;bottom:1px}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{height:1rem;left:calc(-.5rem - 1px);margin:6px 0;width:.5rem}.bs-popover-auto[x-placement^=right]>.arrow:before,.bs-popover-right>.arrow:before{border-right-color:rgba(0,0,0,.25);border-width:.5rem .5rem .5rem 0;left:0}.bs-popover-auto[x-placement^=right]>.arrow:after,.bs-popover-right>.arrow:after{border-right-color:#fff;border-width:.5rem .5rem .5rem 0;left:1px}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow:before,.bs-popover-bottom>.arrow:before{border-bottom-color:rgba(0,0,0,.25);border-width:0 .5rem .5rem;top:0}.bs-popover-auto[x-placement^=bottom]>.arrow:after,.bs-popover-bottom>.arrow:after{border-bottom-color:#fff;border-width:0 .5rem .5rem;top:1px}.bs-popover-auto[x-placement^=bottom] .popover-header:before,.bs-popover-bottom .popover-header:before{border-bottom:1px solid #f7f7f7;content:"";display:block;left:50%;margin-left:-.5rem;position:absolute;top:0;width:1rem}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{height:1rem;margin:6px 0;right:calc(-.5rem - 1px);width:.5rem}.bs-popover-auto[x-placement^=left]>.arrow:before,.bs-popover-left>.arrow:before{border-left-color:rgba(0,0,0,.25);border-width:.5rem 0 .5rem .5rem;right:0}.bs-popover-auto[x-placement^=left]>.arrow:after,.bs-popover-left>.arrow:after{border-left-color:#fff;border-width:.5rem 0 .5rem .5rem;right:1px}.popover-header{background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:5px;border-top-right-radius:5px;font-size:1rem;margin-bottom:0;padding:.5rem .75rem}.popover-header:empty{display:none}.popover-body{color:#f3f4f6;padding:.5rem .75rem}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{overflow:hidden;position:relative;width:100%}.carousel-inner:after{clear:both;content:"";display:block}.carousel-item{backface-visibility:hidden;display:none;float:left;margin-right:-100%;position:relative;transition:transform .6s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transform:none;transition-property:opacity}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{opacity:1;z-index:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{opacity:0;transition:opacity 0s .6s;z-index:0}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{align-items:center;background:none;border:0;bottom:0;color:#fff;display:flex;justify-content:center;opacity:.5;padding:0;position:absolute;text-align:center;top:0;transition:opacity .15s ease;width:15%;z-index:1}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;opacity:.9;outline:0;text-decoration:none}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{background:50%/100% 100% no-repeat;display:inline-block;height:20px;width:20px}.carousel-control-prev-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m5.25 0-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3E%3C/svg%3E")}.carousel-control-next-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m2.75 0-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3E%3C/svg%3E")}.carousel-indicators{bottom:0;display:flex;justify-content:center;left:0;list-style:none;margin-left:15%;margin-right:15%;padding-left:0;position:absolute;right:0;z-index:15}.carousel-indicators li{background-clip:padding-box;background-color:#fff;border-bottom:10px solid transparent;border-top:10px solid transparent;box-sizing:content-box;cursor:pointer;flex:0 1 auto;height:3px;margin-left:3px;margin-right:3px;opacity:.5;text-indent:-999px;transition:opacity .6s ease;width:30px}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{bottom:20px;color:#fff;left:15%;padding-bottom:20px;padding-top:20px;position:absolute;right:15%;text-align:center;z-index:10}@keyframes spinner-border{to{transform:rotate(1turn)}}.spinner-border{animation:spinner-border .75s linear infinite;border:.25em solid;border-radius:50%;border-right:.25em solid transparent;display:inline-block;height:2rem;vertical-align:-.125em;width:2rem}.spinner-border-sm{border-width:.2em;height:1rem;width:1rem}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{animation:spinner-grow .75s linear infinite;background-color:currentcolor;border-radius:50%;display:inline-block;height:2rem;opacity:0;vertical-align:-.125em;width:2rem}.spinner-grow-sm{height:1rem;width:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{animation-duration:1.5s}}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#4040c8!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#3030a5!important}.bg-secondary{background-color:#4b5563!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#353c46!important}.bg-success{background-color:#059669!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#036546!important}.bg-info{background-color:#2563eb!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#134cca!important}.bg-warning{background-color:#d97706!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#a75c05!important}.bg-danger{background-color:#dc2626!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#b21d1d!important}.bg-light{background-color:#f3f4f6!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#d6d9e0!important}.bg-dark{background-color:#1f2937!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#0d1116!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #4b5563!important}.border-top{border-top:1px solid #4b5563!important}.border-right{border-right:1px solid #4b5563!important}.border-bottom{border-bottom:1px solid #4b5563!important}.border-left{border-left:1px solid #4b5563!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#4040c8!important}.border-secondary{border-color:#4b5563!important}.border-success{border-color:#059669!important}.border-info{border-color:#2563eb!important}.border-warning{border-color:#d97706!important}.border-danger{border-color:#dc2626!important}.border-light{border-color:#f3f4f6!important}.border-dark{border-color:#1f2937!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important}.rounded-right,.rounded-top{border-top-right-radius:.25rem!important}.rounded-bottom,.rounded-right{border-bottom-right-radius:.25rem!important}.rounded-bottom,.rounded-left{border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important}.rounded-lg{border-radius:6px!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix:after{clear:both;content:"";display:block}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}@media (min-width:2px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}}@media (min-width:8px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}}@media (min-width:9px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}}@media (min-width:10px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}}.embed-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.embed-responsive:before{content:"";display:block}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{border:0;bottom:0;height:100%;left:0;position:absolute;top:0;width:100%}.embed-responsive-21by9:before{padding-top:42.85714286%}.embed-responsive-16by9:before{padding-top:56.25%}.embed-responsive-4by3:before{padding-top:75%}.embed-responsive-1by1:before{padding-top:100%}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-fill{flex:1 1 auto!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}@media (min-width:2px){.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}}@media (min-width:8px){.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}}@media (min-width:9px){.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}}@media (min-width:10px){.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:2px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:8px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:9px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:10px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.fixed-top{top:0}.fixed-bottom,.fixed-top{left:0;position:fixed;right:0;z-index:1030}.fixed-bottom{bottom:0}@supports (position:sticky){.sticky-top{position:sticky;top:0;z-index:1020}}.sr-only{clip:rect(0,0,0,0);border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;overflow:visible;position:static;white-space:normal;width:auto}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:2px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:8px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:9px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:10px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link:after{background-color:transparent;bottom:0;content:"";left:0;pointer-events:auto;position:absolute;right:0;top:0;z-index:1}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:2px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:8px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:9px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:10px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:600!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#4040c8!important}a.text-primary:focus,a.text-primary:hover{color:#2a2a92!important}.text-secondary{color:#4b5563!important}a.text-secondary:focus,a.text-secondary:hover{color:#2a3037!important}.text-success{color:#059669!important}a.text-success:focus,a.text-success:hover{color:#034c35!important}.text-info{color:#2563eb!important}a.text-info:focus,a.text-info:hover{color:#1043b3!important}.text-warning{color:#d97706!important}a.text-warning:focus,a.text-warning:hover{color:#8f4e04!important}.text-danger{color:#dc2626!important}a.text-danger:focus,a.text-danger:hover{color:#9c1919!important}.text-light{color:#f3f4f6!important}a.text-light:focus,a.text-light:hover{color:#c7ccd5!important}.text-dark{color:#1f2937!important}a.text-dark:focus,a.text-dark:hover{color:#030506!important}.text-body{color:#f3f4f6!important}.text-muted{color:#9ca3af!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:hsla(0,0%,100%,.5)!important}.text-hide{background-color:transparent;border:0;color:transparent;font:0/0 a;text-shadow:none}.text-decoration-none{text-decoration:none!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,:after,:before{box-shadow:none!important;text-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #6b7280}blockquote,img,pre,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}.container,body{min-width:9px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #d1d5db!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#374151}.table .thead-dark th{border-color:#374151;color:inherit}}.vjs-tree{color:#bfc7d5!important;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.vjs-tree .vjs-tree__content{border-left:1px dotted hsla(0,0%,80%,.28)!important}.vjs-tree .vjs-tree__node{cursor:pointer}.vjs-tree .vjs-tree__node:hover{color:#20a0ff}.vjs-tree .vjs-checkbox{left:-30px;position:absolute}.vjs-tree .vjs-value__boolean,.vjs-tree .vjs-value__null,.vjs-tree .vjs-value__number{color:#a291f5!important}.vjs-tree .vjs-value__string{color:#c3e88d!important}.vjs-tree .vjs-key{color:#c3cbd3!important}.hljs-addition,.hljs-attr,.hljs-keyword,.hljs-selector-tag{color:#13ce66}.hljs-bullet,.hljs-meta,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c3e88d}.hljs-comment,.hljs-deletion,.hljs-quote{color:#bfcbd9}.hljs-literal,.hljs-number,.hljs-title{color:#a291f5!important}body{padding-bottom:20px}.container{max-width:1440px}html{min-width:1140px}[v-cloak]{display:none}svg.icon{height:1rem;width:1rem}.header{border-bottom:1px solid #374151}.header .logo{color:#e5e7eb;text-decoration:none}.header .logo svg{height:1.7rem;width:1.7rem}.sidebar .nav-item a{border-radius:6px;color:#9ca3af;margin-bottom:4px;padding:.5rem .75rem}.sidebar .nav-item a svg{fill:#6b7280;height:1.25rem;margin-right:15px;width:1.25rem}.sidebar .nav-item a:hover{background-color:#1f2937;color:#d1d5db}.sidebar .nav-item a.active{background-color:#1f2937;color:#818cf8}.sidebar .nav-item a.active svg{fill:#6366f1}.card{border:none;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)}.card .bottom-radius{border-bottom-left-radius:6px;border-bottom-right-radius:6px}.card .card-header{background-color:#374151;border-bottom:none;min-height:60px;padding-bottom:.7rem;padding-top:.7rem}.card .card-header .btn-group .btn{padding:.2rem .5rem}.card .card-header .form-control-with-icon{position:relative}.card .card-header .form-control-with-icon .icon-wrapper{align-items:center;bottom:0;display:flex;justify-content:center;left:.75rem;position:absolute;top:0}.card .card-header .form-control-with-icon .icon-wrapper .icon{fill:#9ca3af}.card .card-header .form-control-with-icon .form-control{border-radius:9999px;font-size:.875rem;padding-left:2.25rem}.card .table td,.card .table th{padding:.75rem 1.25rem}.card .table th{background-color:#1f2937;border-bottom:0;font-size:.875rem;padding:.5rem 1.25rem}.card .table:not(.table-borderless) td{border-top:1px solid #374151}.card .table.penultimate-column-right td:nth-last-child(2),.card .table.penultimate-column-right th:nth-last-child(2){text-align:right}.card .table td.table-fit,.card .table th.table-fit{white-space:nowrap;width:1%}.fill-text-color{fill:#f3f4f6}.fill-danger{fill:#dc2626}.fill-warning{fill:#d97706}.fill-info{fill:#2563eb}.fill-success{fill:#059669}.fill-primary{fill:#4040c8}button:hover .fill-primary{fill:#fff}.btn-outline-primary.active .fill-primary{fill:#111827}.btn-outline-primary:not(:disabled):not(.disabled).active:focus{box-shadow:none!important}.btn-muted{background:#1f2937;color:#9ca3af}.btn-muted:focus,.btn-muted:hover{background:#374151;color:#d1d5db}.btn-muted.active{background:#4040c8;color:#fff}.badge-secondary{background:#d1d5db;color:#374151}.badge-success{background:#10b981;color:#fff}.badge-info{background:#3b82f6;color:#fff}.badge-warning{background:#f59e0b;color:#fff}.badge-danger{background:#ef4444;color:#fff}.control-action svg{fill:#6b7280;height:1.2rem;width:1.2rem}.control-action svg:hover{fill:#818cf8}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin{animation:spin 2s linear infinite}.card .nav-pills{background:#374151}.card .nav-pills .nav-link{border-radius:0;color:#9ca3af;font-size:.9rem;padding:.75rem 1.25rem}.card .nav-pills .nav-link:focus,.card .nav-pills .nav-link:hover{color:#e5e7eb}.card .nav-pills .nav-link.active{background:none;border-bottom:2px solid #a5b4fc;color:#a5b4fc}.list-enter-active:not(.dontanimate){transition:background 1s linear}.list-enter:not(.dontanimate),.list-leave-to:not(.dontanimate){background:#312e81}.code-bg .list-enter:not(.dontanimate),.code-bg .list-leave-to:not(.dontanimate){background:#4b5563}#indexScreen td{vertical-align:middle!important}.card-bg-secondary{background:#1f2937}.code-bg{background:#292d3e}.disabled-watcher{background:#dc2626;color:#fff;padding:.75rem}.copy-to-clipboard{--tw-text-opacity:1;color:rgb(231 232 242/var(--tw-text-opacity));opacity:.7;outline:2px solid transparent;outline-offset:2px;position:absolute;right:0;top:0;z-index:10} diff --git a/public/vendor/telescope/app.css b/public/vendor/telescope/app.css index fac021b32..1f6375bf1 100644 --- a/public/vendor/telescope/app.css +++ b/public/vendor/telescope/app.css @@ -1,8 +1,7 @@ @charset "UTF-8"; - /*! * Bootstrap v4.6.2 (https://getbootstrap.com/) * Copyright 2011-2022 The Bootstrap Authors * Copyright 2011-2022 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#4b5563;--gray-dark:#1f2937;--primary:#4040c8;--secondary:#4b5563;--success:#059669;--info:#2563eb;--warning:#d97706;--danger:#dc2626;--light:#f3f4f6;--dark:#1f2937;--breakpoint-xs:0;--breakpoint-sm:2px;--breakpoint-md:8px;--breakpoint-lg:9px;--breakpoint-xl:10px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,:after,:before{box-sizing:border-box}html{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);font-family:sans-serif;line-height:1.15}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{background-color:#f3f4f6;color:#111827;font-family:Figtree,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;margin:0;text-align:left}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;margin-top:0}p{margin-bottom:1rem;margin-top:0}abbr[data-original-title],abbr[title]{border-bottom:0;cursor:help;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit}address,dl,ol,ul{margin-bottom:1rem}dl,ol,ul{margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:600}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{background-color:transparent;color:#6366f1;text-decoration:none}a:hover{color:#4f46e5;text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}pre{-ms-overflow-style:scrollbar;margin-bottom:1rem;margin-top:0;overflow:auto}figure{margin:0 0 1rem}img{border-style:none}img,svg{vertical-align:middle}svg{overflow:hidden}table{border-collapse:collapse}caption{caption-side:bottom;color:#6b7280;padding-bottom:.75rem;padding-top:.75rem;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{color:inherit;display:block;font-size:1.5rem;line-height:inherit;margin-bottom:.5rem;max-width:100%;padding:0;white-space:normal;width:100%}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:none;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}output{display:inline-block}summary{cursor:pointer;display:list-item}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-weight:500;line-height:1.2;margin-bottom:.5rem}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem}.display-1,.display-2{font-weight:300;line-height:1.2}.display-2{font-size:5.5rem}.display-3{font-size:4.5rem}.display-3,.display-4{font-weight:300;line-height:1.2}.display-4{font-size:3.5rem}hr{border:0;border-top:1px solid rgba(0,0,0,.1);margin-bottom:1rem;margin-top:1rem}.small,small{font-size:.875em;font-weight:400}.mark,mark{background-color:#fcf8e3;padding:.2em}.list-inline,.list-unstyled{list-style:none;padding-left:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{font-size:1.25rem;margin-bottom:1rem}.blockquote-footer{color:#4b5563;display:block;font-size:.875em}.blockquote-footer:before{content:"— "}.img-fluid,.img-thumbnail{height:auto;max-width:100%}.img-thumbnail{background-color:#f3f4f6;border:1px solid #d1d5db;border-radius:.25rem;padding:.25rem}.figure{display:inline-block}.figure-img{line-height:1;margin-bottom:.5rem}.figure-caption{color:#4b5563;font-size:90%}code{word-wrap:break-word;color:#e83e8c;font-size:87.5%}a>code{color:inherit}kbd{background-color:#111827;border-radius:.2rem;color:#fff;font-size:87.5%;padding:.2rem .4rem}kbd kbd{font-size:100%;font-weight:600;padding:0}pre{color:#111827;display:block;font-size:87.5%}pre code{color:inherit;font-size:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{margin-left:auto;margin-right:auto;padding-left:15px;padding-right:15px;width:100%}@media (min-width:2px){.container,.container-sm{max-width:1137px}}@media (min-width:8px){.container,.container-md,.container-sm{max-width:1138px}}@media (min-width:9px){.container,.container-lg,.container-md,.container-sm{max-width:1139px}}@media (min-width:10px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:flex;flex-wrap:wrap;margin-left:-15px;margin-right:-15px}.no-gutters{margin-left:0;margin-right:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-left:0;padding-right:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{padding-left:15px;padding-right:15px;position:relative;width:100%}.col{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-1>*{flex:0 0 100%;max-width:100%}.row-cols-2>*{flex:0 0 50%;max-width:50%}.row-cols-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-4>*{flex:0 0 25%;max-width:25%}.row-cols-5>*{flex:0 0 20%;max-width:20%}.row-cols-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-auto{flex:0 0 auto;max-width:100%;width:auto}.col-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-3{flex:0 0 25%;max-width:25%}.col-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-6{flex:0 0 50%;max-width:50%}.col-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-9{flex:0 0 75%;max-width:75%}.col-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-12{flex:0 0 100%;max-width:100%}.order-first{order:-1}.order-last{order:13}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-9{order:9}.order-10{order:10}.order-11{order:11}.order-12{order:12}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}@media (min-width:2px){.col-sm{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-sm-1>*{flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-auto{flex:0 0 auto;max-width:100%;width:auto}.col-sm-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-sm-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-sm-3{flex:0 0 25%;max-width:25%}.col-sm-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-sm-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-sm-6{flex:0 0 50%;max-width:50%}.col-sm-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-sm-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-sm-9{flex:0 0 75%;max-width:75%}.col-sm-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-sm-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-sm-12{flex:0 0 100%;max-width:100%}.order-sm-first{order:-1}.order-sm-last{order:13}.order-sm-0{order:0}.order-sm-1{order:1}.order-sm-2{order:2}.order-sm-3{order:3}.order-sm-4{order:4}.order-sm-5{order:5}.order-sm-6{order:6}.order-sm-7{order:7}.order-sm-8{order:8}.order-sm-9{order:9}.order-sm-10{order:10}.order-sm-11{order:11}.order-sm-12{order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}}@media (min-width:8px){.col-md{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-md-1>*{flex:0 0 100%;max-width:100%}.row-cols-md-2>*{flex:0 0 50%;max-width:50%}.row-cols-md-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-md-4>*{flex:0 0 25%;max-width:25%}.row-cols-md-5>*{flex:0 0 20%;max-width:20%}.row-cols-md-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-auto{flex:0 0 auto;max-width:100%;width:auto}.col-md-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-md-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-md-3{flex:0 0 25%;max-width:25%}.col-md-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-md-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-md-6{flex:0 0 50%;max-width:50%}.col-md-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-md-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-md-9{flex:0 0 75%;max-width:75%}.col-md-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-md-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-md-12{flex:0 0 100%;max-width:100%}.order-md-first{order:-1}.order-md-last{order:13}.order-md-0{order:0}.order-md-1{order:1}.order-md-2{order:2}.order-md-3{order:3}.order-md-4{order:4}.order-md-5{order:5}.order-md-6{order:6}.order-md-7{order:7}.order-md-8{order:8}.order-md-9{order:9}.order-md-10{order:10}.order-md-11{order:11}.order-md-12{order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}}@media (min-width:9px){.col-lg{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-lg-1>*{flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-auto{flex:0 0 auto;max-width:100%;width:auto}.col-lg-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-lg-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-lg-3{flex:0 0 25%;max-width:25%}.col-lg-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-lg-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-lg-6{flex:0 0 50%;max-width:50%}.col-lg-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-lg-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-lg-9{flex:0 0 75%;max-width:75%}.col-lg-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-lg-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-lg-12{flex:0 0 100%;max-width:100%}.order-lg-first{order:-1}.order-lg-last{order:13}.order-lg-0{order:0}.order-lg-1{order:1}.order-lg-2{order:2}.order-lg-3{order:3}.order-lg-4{order:4}.order-lg-5{order:5}.order-lg-6{order:6}.order-lg-7{order:7}.order-lg-8{order:8}.order-lg-9{order:9}.order-lg-10{order:10}.order-lg-11{order:11}.order-lg-12{order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}}@media (min-width:10px){.col-xl{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-xl-1>*{flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-auto{flex:0 0 auto;max-width:100%;width:auto}.col-xl-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-xl-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-xl-3{flex:0 0 25%;max-width:25%}.col-xl-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-xl-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-xl-6{flex:0 0 50%;max-width:50%}.col-xl-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-xl-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-xl-9{flex:0 0 75%;max-width:75%}.col-xl-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-xl-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-xl-12{flex:0 0 100%;max-width:100%}.order-xl-first{order:-1}.order-xl-last{order:13}.order-xl-0{order:0}.order-xl-1{order:1}.order-xl-2{order:2}.order-xl-3{order:3}.order-xl-4{order:4}.order-xl-5{order:5}.order-xl-6{order:6}.order-xl-7{order:7}.order-xl-8{order:8}.order-xl-9{order:9}.order-xl-10{order:10}.order-xl-11{order:11}.order-xl-12{order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}}.table{color:#111827;margin-bottom:1rem;width:100%}.table td,.table th{border-top:1px solid #e5e7eb;padding:.75rem;vertical-align:top}.table thead th{border-bottom:2px solid #e5e7eb;vertical-align:bottom}.table tbody+tbody{border-top:2px solid #e5e7eb}.table-sm td,.table-sm th{padding:.3rem}.table-bordered,.table-bordered td,.table-bordered th{border:1px solid #e5e7eb}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{background-color:#f3f4f6;color:#111827}.table-primary,.table-primary>td,.table-primary>th{background-color:#cacaf0}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#9c9ce2}.table-hover .table-primary:hover,.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b6b6ea}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#cdcfd3}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#a1a7ae}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#bfc2c7}.table-success,.table-success>td,.table-success>th{background-color:#b9e2d5}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#7dc8b1}.table-hover .table-success:hover,.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#a7dbca}.table-info,.table-info>td,.table-info>th{background-color:#c2d3f9}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#8eaef5}.table-hover .table-info:hover,.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abc2f7}.table-warning,.table-warning>td,.table-warning>th{background-color:#f4d9b9}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ebb87e}.table-hover .table-warning:hover,.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#f1cda3}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c2c2}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed8e8e}.table-hover .table-danger:hover,.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1acac}.table-light,.table-light>td,.table-light>th{background-color:#fcfcfc}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#f9f9fa}.table-hover .table-light:hover,.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#efefef}.table-dark,.table-dark>td,.table-dark>th{background-color:#c0c3c7}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#8b9097}.table-hover .table-dark:hover,.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b3b6bb}.table-active,.table-active>td,.table-active>th{background-color:#f3f4f6}.table-hover .table-active:hover,.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:#e4e7eb}.table .thead-dark th{background-color:#1f2937;border-color:#2d3b4f;color:#fff}.table .thead-light th{background-color:#e5e7eb;border-color:#e5e7eb;color:#374151}.table-dark{background-color:#1f2937;color:#fff}.table-dark td,.table-dark th,.table-dark thead th{border-color:#2d3b4f}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:hsla(0,0%,100%,.05)}.table-dark.table-hover tbody tr:hover{background-color:hsla(0,0%,100%,.075);color:#fff}@media (max-width:1.98px){.table-responsive-sm{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:7.98px){.table-responsive-md{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-md>.table-bordered{border:0}}@media (max-width:8.98px){.table-responsive-lg{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:9.98px){.table-responsive-xl{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive>.table-bordered{border:0}.form-control{background-clip:padding-box;background-color:#fff;border:1px solid #d1d5db;border-radius:.25rem;color:#1f2937;display:block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem .75rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{background-color:#fff;border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);color:#1f2937;outline:0}.form-control::-moz-placeholder{color:#4b5563;opacity:1}.form-control::placeholder{color:#4b5563;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e5e7eb;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #1f2937}select.form-control:focus::-ms-value{background-color:#fff;color:#1f2937}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{font-size:inherit;line-height:1.5;margin-bottom:0;padding-bottom:calc(.375rem + 1px);padding-top:calc(.375rem + 1px)}.col-form-label-lg{font-size:1.25rem;line-height:1.5;padding-bottom:calc(.5rem + 1px);padding-top:calc(.5rem + 1px)}.col-form-label-sm{font-size:.875rem;line-height:1.5;padding-bottom:calc(.25rem + 1px);padding-top:calc(.25rem + 1px)}.form-control-plaintext{background-color:transparent;border:solid transparent;border-width:1px 0;color:#111827;display:block;font-size:1rem;line-height:1.5;margin-bottom:0;padding:.375rem 0;width:100%}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-left:0;padding-right:0}.form-control-sm{border-radius:.2rem;font-size:.875rem;height:calc(1.5em + .5rem + 2px);line-height:1.5;padding:.25rem .5rem}.form-control-lg{border-radius:6px;font-size:1.25rem;height:calc(1.5em + 1rem + 2px);line-height:1.5;padding:.5rem 1rem}select.form-control[multiple],select.form-control[size],textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:flex;flex-wrap:wrap;margin-left:-5px;margin-right:-5px}.form-row>.col,.form-row>[class*=col-]{padding-left:5px;padding-right:5px}.form-check{display:block;padding-left:1.25rem;position:relative}.form-check-input{margin-left:-1.25rem;margin-top:.3rem;position:absolute}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#6b7280}.form-check-label{margin-bottom:0}.form-check-inline{align-items:center;display:inline-flex;margin-right:.75rem;padding-left:0}.form-check-inline .form-check-input{margin-left:0;margin-right:.3125rem;margin-top:0;position:static}.valid-feedback{color:#059669;display:none;font-size:.875em;margin-top:.25rem;width:100%}.valid-tooltip{background-color:rgba(5,150,105,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.valid-tooltip,.form-row>[class*=col-]>.valid-tooltip{left:5px}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#059669;padding-right:calc(1.5em + .75rem)!important}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.was-validated select.form-control:valid,select.form-control.is-valid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-valid,.was-validated .custom-select:valid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#059669;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#059669}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#059669}.custom-control-input.is-valid~.custom-control-label:before,.was-validated .custom-control-input:valid~.custom-control-label:before{border-color:#059669}.custom-control-input.is-valid:checked~.custom-control-label:before,.was-validated .custom-control-input:valid:checked~.custom-control-label:before{background-color:#07c78c;border-color:#07c78c}.custom-control-input.is-valid:focus~.custom-control-label:before,.was-validated .custom-control-input:valid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label:before{border-color:#059669}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#059669}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.invalid-feedback{color:#dc2626;display:none;font-size:.875em;margin-top:.25rem;width:100%}.invalid-tooltip{background-color:rgba(220,38,38,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.invalid-tooltip,.form-row>[class*=col-]>.invalid-tooltip{left:5px}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#dc2626;padding-right:calc(1.5em + .75rem)!important}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.was-validated select.form-control:invalid,select.form-control.is-invalid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#dc2626;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc2626}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc2626}.custom-control-input.is-invalid~.custom-control-label:before,.was-validated .custom-control-input:invalid~.custom-control-label:before{border-color:#dc2626}.custom-control-input.is-invalid:checked~.custom-control-label:before,.was-validated .custom-control-input:invalid:checked~.custom-control-label:before{background-color:#e35252;border-color:#e35252}.custom-control-input.is-invalid:focus~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label:before{border-color:#dc2626}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc2626}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-inline{align-items:center;display:flex;flex-flow:row wrap}.form-inline .form-check{width:100%}@media (min-width:2px){.form-inline label{justify-content:center}.form-inline .form-group,.form-inline label{align-items:center;display:flex;margin-bottom:0}.form-inline .form-group{flex:0 0 auto;flex-flow:row wrap}.form-inline .form-control{display:inline-block;vertical-align:middle;width:auto}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{align-items:center;display:flex;justify-content:center;padding-left:0;width:auto}.form-inline .form-check-input{flex-shrink:0;margin-left:0;margin-right:.25rem;margin-top:0;position:relative}.form-inline .custom-control{align-items:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{background-color:transparent;border:1px solid transparent;border-radius:.25rem;color:#111827;display:inline-block;font-size:1rem;font-weight:400;line-height:1.5;padding:.375rem .75rem;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#111827;text-decoration:none}.btn.focus,.btn:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary.focus,.btn-primary:focus,.btn-primary:hover{background-color:#3232af;border-color:#3030a5;color:#fff}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 0 rgba(93,93,208,.5)}.btn-primary.disabled,.btn-primary:disabled{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{background-color:#3030a5;border-color:#2d2d9b;color:#fff}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(93,93,208,.5)}.btn-secondary{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary.focus,.btn-secondary:focus,.btn-secondary:hover{background-color:#3b424d;border-color:#353c46;color:#fff}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 0 hsla(213,9%,44%,.5)}.btn-secondary.disabled,.btn-secondary:disabled{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{background-color:#353c46;border-color:#30363f;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 0 hsla(213,9%,44%,.5)}.btn-success{background-color:#059669;border-color:#059669;color:#fff}.btn-success.focus,.btn-success:focus,.btn-success:hover{background-color:#04714f;border-color:#036546;color:#fff}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 0 rgba(43,166,128,.5)}.btn-success.disabled,.btn-success:disabled{background-color:#059669;border-color:#059669;color:#fff}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{background-color:#036546;border-color:#03583e;color:#fff}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(43,166,128,.5)}.btn-info{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info.focus,.btn-info:focus,.btn-info:hover{background-color:#1451d6;border-color:#134cca;color:#fff}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 0 rgba(70,122,238,.5)}.btn-info.disabled,.btn-info:disabled{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{background-color:#134cca;border-color:#1248bf;color:#fff}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(70,122,238,.5)}.btn-warning{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning.focus,.btn-warning:focus,.btn-warning:hover{background-color:#b46305;border-color:#a75c05;color:#fff}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 0 rgba(223,139,43,.5)}.btn-warning.disabled,.btn-warning:disabled{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{background-color:#a75c05;border-color:#9b5504;color:#fff}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(223,139,43,.5)}.btn-danger{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger.focus,.btn-danger:focus,.btn-danger:hover{background-color:#bd1f1f;border-color:#b21d1d;color:#fff}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 0 rgba(225,71,71,.5)}.btn-danger.disabled,.btn-danger:disabled{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{background-color:#b21d1d;border-color:#a71b1b;color:#fff}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(225,71,71,.5)}.btn-light{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light.focus,.btn-light:focus,.btn-light:hover{background-color:#dde0e6;border-color:#d6d9e0;color:#111827}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 0 hsla(220,7%,83%,.5)}.btn-light.disabled,.btn-light:disabled{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{background-color:#d6d9e0;border-color:#cfd3db;color:#111827}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 0 hsla(220,7%,83%,.5)}.btn-dark{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark.focus,.btn-dark:focus,.btn-dark:hover{background-color:#11171f;border-color:#0d1116;color:#fff}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 0 rgba(65,73,85,.5)}.btn-dark.disabled,.btn-dark:disabled{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{background-color:#0d1116;border-color:#080b0e;color:#fff}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(65,73,85,.5)}.btn-outline-primary{border-color:#4040c8;color:#4040c8}.btn-outline-primary:hover{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 0 rgba(64,64,200,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{background-color:transparent;color:#4040c8}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(64,64,200,.5)}.btn-outline-secondary{border-color:#4b5563;color:#4b5563}.btn-outline-secondary:hover{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 0 rgba(75,85,99,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{background-color:transparent;color:#4b5563}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(75,85,99,.5)}.btn-outline-success{border-color:#059669;color:#059669}.btn-outline-success:hover{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 0 rgba(5,150,105,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{background-color:transparent;color:#059669}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(5,150,105,.5)}.btn-outline-info{border-color:#2563eb;color:#2563eb}.btn-outline-info:hover{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 0 rgba(37,99,235,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{background-color:transparent;color:#2563eb}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(37,99,235,.5)}.btn-outline-warning{border-color:#d97706;color:#d97706}.btn-outline-warning:hover{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 0 rgba(217,119,6,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{background-color:transparent;color:#d97706}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(217,119,6,.5)}.btn-outline-danger{border-color:#dc2626;color:#dc2626}.btn-outline-danger:hover{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 0 rgba(220,38,38,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{background-color:transparent;color:#dc2626}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(220,38,38,.5)}.btn-outline-light{border-color:#f3f4f6;color:#f3f4f6}.btn-outline-light:hover{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 0 rgba(243,244,246,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{background-color:transparent;color:#f3f4f6}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(243,244,246,.5)}.btn-outline-dark{border-color:#1f2937;color:#1f2937}.btn-outline-dark:hover{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 0 rgba(31,41,55,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{background-color:transparent;color:#1f2937}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(31,41,55,.5)}.btn-link{color:#6366f1;font-weight:400;text-decoration:none}.btn-link:hover{color:#4f46e5}.btn-link.focus,.btn-link:focus,.btn-link:hover{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#4b5563;pointer-events:none}.btn-group-lg>.btn,.btn-lg{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.btn-group-sm>.btn,.btn-sm{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;position:relative;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.width{height:auto;transition:width .35s ease;width:0}@media (prefers-reduced-motion:reduce){.collapsing.width{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle:after{border-bottom:0;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropdown-toggle:empty:after{margin-left:0}.dropdown-menu{background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.25rem;color:#111827;display:none;float:left;font-size:1rem;left:0;list-style:none;margin:.125rem 0 0;min-width:10rem;padding:.5rem 0;position:absolute;text-align:left;top:100%;z-index:1000}.dropdown-menu-left{left:0;right:auto}.dropdown-menu-right{left:auto;right:0}@media (min-width:2px){.dropdown-menu-sm-left{left:0;right:auto}.dropdown-menu-sm-right{left:auto;right:0}}@media (min-width:8px){.dropdown-menu-md-left{left:0;right:auto}.dropdown-menu-md-right{left:auto;right:0}}@media (min-width:9px){.dropdown-menu-lg-left{left:0;right:auto}.dropdown-menu-lg-right{left:auto;right:0}}@media (min-width:10px){.dropdown-menu-xl-left{left:0;right:auto}.dropdown-menu-xl-right{left:auto;right:0}}.dropup .dropdown-menu{bottom:100%;margin-bottom:.125rem;margin-top:0;top:auto}.dropup .dropdown-toggle:after{border-bottom:.3em solid;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:0;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropup .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-menu{left:100%;margin-left:.125rem;margin-top:0;right:auto;top:0}.dropright .dropdown-toggle:after{border-bottom:.3em solid transparent;border-left:.3em solid;border-right:0;border-top:.3em solid transparent;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropright .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-toggle:after{vertical-align:0}.dropleft .dropdown-menu{left:auto;margin-right:.125rem;margin-top:0;right:100%;top:0}.dropleft .dropdown-toggle:after{content:"";display:inline-block;display:none;margin-left:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:before{border-bottom:.3em solid transparent;border-right:.3em solid;border-top:.3em solid transparent;content:"";display:inline-block;margin-right:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:empty:after{margin-left:0}.dropleft .dropdown-toggle:before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{bottom:auto;right:auto}.dropdown-divider{border-top:1px solid #e5e7eb;height:0;margin:.5rem 0;overflow:hidden}.dropdown-item{background-color:transparent;border:0;clear:both;color:#374151;display:block;font-weight:400;padding:.25rem 1.5rem;text-align:inherit;white-space:nowrap;width:100%}.dropdown-item:focus,.dropdown-item:hover{background-color:#e5e7eb;color:#090d15;text-decoration:none}.dropdown-item.active,.dropdown-item:active{background-color:#4040c8;color:#fff;text-decoration:none}.dropdown-item.disabled,.dropdown-item:disabled{background-color:transparent;color:#6b7280;pointer-events:none}.dropdown-menu.show{display:block}.dropdown-header{color:#4b5563;display:block;font-size:.875rem;margin-bottom:0;padding:.5rem 1.5rem;white-space:nowrap}.dropdown-item-text{color:#374151;display:block;padding:.25rem 1.5rem}.btn-group,.btn-group-vertical{display:inline-flex;position:relative;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{flex:1 1 auto;position:relative}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.dropdown-toggle-split{padding-left:.5625rem;padding-right:.5625rem}.dropdown-toggle-split:after,.dropright .dropdown-toggle-split:after,.dropup .dropdown-toggle-split:after{margin-left:0}.dropleft .dropdown-toggle-split:before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-left:.375rem;padding-right:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-left:.75rem;padding-right:.75rem}.btn-group-vertical{align-items:flex-start;flex-direction:column;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-left-radius:0;border-bottom-right-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{clip:rect(0,0,0,0);pointer-events:none;position:absolute}.input-group{align-items:stretch;display:flex;flex-wrap:wrap;position:relative;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{flex:1 1 auto;margin-bottom:0;min-width:0;position:relative;width:1%}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.input-group>.custom-file{align-items:center;display:flex}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label:after{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-bottom-left-radius:0;border-top-left-radius:0}.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label,.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label:after,.input-group.has-validation>.custom-select:nth-last-child(n+3),.input-group.has-validation>.form-control:nth-last-child(n+3),.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label,.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label:after,.input-group:not(.has-validation)>.custom-select:not(:last-child),.input-group:not(.has-validation)>.form-control:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group-append,.input-group-prepend{display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{align-items:center;background-color:#e5e7eb;border:1px solid #d1d5db;border-radius:.25rem;color:#1f2937;display:flex;font-size:1rem;font-weight:400;line-height:1.5;margin-bottom:0;padding:.375rem .75rem;text-align:center;white-space:nowrap}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.btn,.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.input-group-text,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.btn,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-bottom-left-radius:0;border-top-left-radius:0}.custom-control{display:block;min-height:1.5rem;padding-left:1.5rem;position:relative;-webkit-print-color-adjust:exact;print-color-adjust:exact;z-index:1}.custom-control-inline{display:inline-flex;margin-right:1rem}.custom-control-input{height:1.25rem;left:0;opacity:0;position:absolute;width:1rem;z-index:-1}.custom-control-input:checked~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8;color:#fff}.custom-control-input:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label:before{border-color:#a3a3e5}.custom-control-input:not(:disabled):active~.custom-control-label:before{background-color:#cbcbf0;border-color:#cbcbf0;color:#fff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#4b5563}.custom-control-input:disabled~.custom-control-label:before,.custom-control-input[disabled]~.custom-control-label:before{background-color:#e5e7eb}.custom-control-label{margin-bottom:0;position:relative;vertical-align:top}.custom-control-label:before{background-color:#fff;border:1px solid #6b7280;pointer-events:none}.custom-control-label:after,.custom-control-label:before{content:"";display:block;height:1rem;left:-1.5rem;position:absolute;top:.25rem;width:1rem}.custom-control-label:after{background:50%/50% 50% no-repeat}.custom-checkbox .custom-control-label:before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23fff' d='m6.564.75-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-radio .custom-control-label:before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label:before{border-radius:.5rem;left:-2.25rem;pointer-events:all;width:1.75rem}.custom-switch .custom-control-label:after{background-color:#6b7280;border-radius:.5rem;height:calc(1rem - 4px);left:calc(-2.25rem + 2px);top:calc(.25rem + 2px);transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:calc(1rem - 4px)}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label:after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label:after{background-color:#fff;transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat;border:1px solid #d1d5db;border-radius:.25rem;color:#1f2937;display:inline-block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem 1.75rem .375rem .75rem;vertical-align:middle;width:100%}.custom-select:focus{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.custom-select:focus::-ms-value{background-color:#fff;color:#1f2937}.custom-select[multiple],.custom-select[size]:not([size="1"]){background-image:none;height:auto;padding-right:.75rem}.custom-select:disabled{background-color:#e5e7eb;color:#4b5563}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #1f2937}.custom-select-sm{font-size:.875rem;height:calc(1.5em + .5rem + 2px);padding-bottom:.25rem;padding-left:.5rem;padding-top:.25rem}.custom-select-lg{font-size:1.25rem;height:calc(1.5em + 1rem + 2px);padding-bottom:.5rem;padding-left:1rem;padding-top:.5rem}.custom-file{display:inline-block;margin-bottom:0}.custom-file,.custom-file-input{height:calc(1.5em + .75rem + 2px);position:relative;width:100%}.custom-file-input{margin:0;opacity:0;overflow:hidden;z-index:2}.custom-file-input:focus~.custom-file-label{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e5e7eb}.custom-file-input:lang(en)~.custom-file-label:after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]:after{content:attr(data-browse)}.custom-file-label{background-color:#fff;border:1px solid #d1d5db;border-radius:.25rem;font-weight:400;height:calc(1.5em + .75rem + 2px);left:0;overflow:hidden;z-index:1}.custom-file-label,.custom-file-label:after{color:#1f2937;line-height:1.5;padding:.375rem .75rem;position:absolute;right:0;top:0}.custom-file-label:after{background-color:#e5e7eb;border-left:inherit;border-radius:0 .25rem .25rem 0;bottom:0;content:"Browse";display:block;height:calc(1.5em + .75rem);z-index:3}.custom-range{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;height:1.4rem;padding:0;width:100%}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #f3f4f6,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #f3f4f6,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #f3f4f6,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-top:-.25rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#cbcbf0}.custom-range::-webkit-slider-runnable-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-moz-range-thumb{-moz-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#cbcbf0}.custom-range::-moz-range-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-thumb{appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-left:.2rem;margin-right:.2rem;margin-top:0;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#cbcbf0}.custom-range::-ms-track{background-color:transparent;border-color:transparent;border-width:.5rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-fill-lower,.custom-range::-ms-fill-upper{background-color:#d1d5db;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px}.custom-range:disabled::-webkit-slider-thumb{background-color:#6b7280}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#6b7280}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#6b7280}.custom-control-label:before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label:before,.custom-file-label,.custom-select{transition:none}}.nav{display:flex;flex-wrap:wrap;list-style:none;margin-bottom:0;padding-left:0}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#4b5563;cursor:default;pointer-events:none}.nav-tabs{border-bottom:1px solid #d1d5db}.nav-tabs .nav-link{background-color:transparent;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem;margin-bottom:-1px}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e5e7eb #e5e7eb #d1d5db;isolation:isolate}.nav-tabs .nav-link.disabled{background-color:transparent;border-color:transparent;color:#4b5563}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{background-color:#f3f4f6;border-color:#d1d5db #d1d5db #f3f4f6;color:#374151}.nav-tabs .dropdown-menu{border-top-left-radius:0;border-top-right-radius:0;margin-top:-1px}.nav-pills .nav-link{background:none;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{background-color:#e5e7eb;color:#fff}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{padding:.5rem 1rem;position:relative}.navbar,.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{align-items:center;display:flex;flex-wrap:wrap;justify-content:space-between}.navbar-brand{display:inline-block;font-size:1.25rem;line-height:inherit;margin-right:1rem;padding-bottom:.3125rem;padding-top:.3125rem;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:flex;flex-direction:column;list-style:none;margin-bottom:0;padding-left:0}.navbar-nav .nav-link{padding-left:0;padding-right:0}.navbar-nav .dropdown-menu{float:none;position:static}.navbar-text{display:inline-block;padding-bottom:.5rem;padding-top:.5rem}.navbar-collapse{align-items:center;flex-basis:100%;flex-grow:1}.navbar-toggler{background-color:transparent;border:1px solid transparent;border-radius:.25rem;font-size:1.25rem;line-height:1;padding:.25rem .75rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{background:50%/100% 100% no-repeat;content:"";display:inline-block;height:1.5em;vertical-align:middle;width:1.5em}.navbar-nav-scroll{max-height:75vh;overflow-y:auto}@media (max-width:1.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-left:0;padding-right:0}}@media (min-width:2px){.navbar-expand-sm{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{flex-wrap:nowrap}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:7.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-left:0;padding-right:0}}@media (min-width:8px){.navbar-expand-md{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{flex-wrap:nowrap}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:8.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-left:0;padding-right:0}}@media (min-width:9px){.navbar-expand-lg{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{flex-wrap:nowrap}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:9.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-left:0;padding-right:0}}@media (min-width:10px){.navbar-expand-xl{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{flex-wrap:nowrap}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-left:0;padding-right:0}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{flex-wrap:nowrap}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand,.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{border-color:rgba(0,0,0,.1);color:rgba(0,0,0,.5)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand,.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:hsla(0,0%,100%,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:hsla(0,0%,100%,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{border-color:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-dark .navbar-text{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{word-wrap:break-word;background-clip:border-box;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:6px;display:flex;flex-direction:column;min-width:0;position:relative}.card>hr{margin-left:0;margin-right:0}.card>.list-group{border-bottom:inherit;border-top:inherit}.card>.list-group:first-child{border-top-left-radius:5px;border-top-right-radius:5px;border-top-width:0}.card>.list-group:last-child{border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-bottom-width:0}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125);margin-bottom:0;padding:.75rem 1.25rem}.card-header:first-child{border-radius:5px 5px 0 0}.card-footer{background-color:#fff;border-top:1px solid rgba(0,0,0,.125);padding:.75rem 1.25rem}.card-footer:last-child{border-radius:0 0 5px 5px}.card-header-tabs{border-bottom:0;margin-bottom:-.75rem}.card-header-pills,.card-header-tabs{margin-left:-.625rem;margin-right:-.625rem}.card-img-overlay{border-radius:5px;bottom:0;left:0;padding:1.25rem;position:absolute;right:0;top:0}.card-img,.card-img-bottom,.card-img-top{flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:5px;border-top-right-radius:5px}.card-img,.card-img-bottom{border-bottom-left-radius:5px;border-bottom-right-radius:5px}.card-deck .card{margin-bottom:15px}@media (min-width:2px){.card-deck{display:flex;flex-flow:row wrap;margin-left:-15px;margin-right:-15px}.card-deck .card{flex:1 0 0%;margin-bottom:0;margin-left:15px;margin-right:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:2px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{border-left:0;margin-left:0}.card-group>.card:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:2px){.card-columns{-moz-column-count:3;column-count:3;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{background-color:#e5e7eb;border-radius:.25rem;display:flex;flex-wrap:wrap;list-style:none;margin-bottom:1rem;padding:.75rem 1rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item:before{color:#4b5563;content:"/";float:left;padding-right:.5rem}.breadcrumb-item+.breadcrumb-item:hover:before{text-decoration:underline;text-decoration:none}.breadcrumb-item.active{color:#4b5563}.pagination{border-radius:.25rem;display:flex;list-style:none;padding-left:0}.page-link{background-color:#fff;border:1px solid #d1d5db;color:#6366f1;display:block;line-height:1.25;margin-left:-1px;padding:.5rem .75rem;position:relative}.page-link:hover{background-color:#e5e7eb;border-color:#d1d5db;color:#4f46e5;text-decoration:none;z-index:2}.page-link:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0;z-index:3}.page-item:first-child .page-link{border-bottom-left-radius:.25rem;border-top-left-radius:.25rem;margin-left:0}.page-item:last-child .page-link{border-bottom-right-radius:.25rem;border-top-right-radius:.25rem}.page-item.active .page-link{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:3}.page-item.disabled .page-link{background-color:#fff;border-color:#d1d5db;color:#4b5563;cursor:auto;pointer-events:none}.pagination-lg .page-link{font-size:1.25rem;line-height:1.5;padding:.75rem 1.5rem}.pagination-lg .page-item:first-child .page-link{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg .page-item:last-child .page-link{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm .page-link{font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.pagination-sm .page-item:first-child .page-link{border-bottom-left-radius:.2rem;border-top-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-bottom-right-radius:.2rem;border-top-right-radius:.2rem}.badge{border-radius:.25rem;display:inline-block;font-size:.875rem;font-weight:600;line-height:1;padding:.25em .4em;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;vertical-align:baseline;white-space:nowrap}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{border-radius:10rem;padding-left:.6em;padding-right:.6em}.badge-primary{background-color:#4040c8;color:#fff}a.badge-primary:focus,a.badge-primary:hover{background-color:#3030a5;color:#fff}a.badge-primary.focus,a.badge-primary:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5);outline:0}.badge-secondary{background-color:#4b5563;color:#fff}a.badge-secondary:focus,a.badge-secondary:hover{background-color:#353c46;color:#fff}a.badge-secondary.focus,a.badge-secondary:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5);outline:0}.badge-success{background-color:#059669;color:#fff}a.badge-success:focus,a.badge-success:hover{background-color:#036546;color:#fff}a.badge-success.focus,a.badge-success:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5);outline:0}.badge-info{background-color:#2563eb;color:#fff}a.badge-info:focus,a.badge-info:hover{background-color:#134cca;color:#fff}a.badge-info.focus,a.badge-info:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5);outline:0}.badge-warning{background-color:#d97706;color:#fff}a.badge-warning:focus,a.badge-warning:hover{background-color:#a75c05;color:#fff}a.badge-warning.focus,a.badge-warning:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5);outline:0}.badge-danger{background-color:#dc2626;color:#fff}a.badge-danger:focus,a.badge-danger:hover{background-color:#b21d1d;color:#fff}a.badge-danger.focus,a.badge-danger:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5);outline:0}.badge-light{background-color:#f3f4f6;color:#111827}a.badge-light:focus,a.badge-light:hover{background-color:#d6d9e0;color:#111827}a.badge-light.focus,a.badge-light:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5);outline:0}.badge-dark{background-color:#1f2937;color:#fff}a.badge-dark:focus,a.badge-dark:hover{background-color:#0d1116;color:#fff}a.badge-dark.focus,a.badge-dark:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5);outline:0}.jumbotron{background-color:#e5e7eb;border-radius:6px;margin-bottom:2rem;padding:2rem 1rem}@media (min-width:2px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{border-radius:0;padding-left:0;padding-right:0}.alert{border:1px solid transparent;border-radius:.25rem;margin-bottom:1rem;padding:.75rem 1.25rem;position:relative}.alert-heading{color:inherit}.alert-link{font-weight:600}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{color:inherit;padding:.75rem 1.25rem;position:absolute;right:0;top:0;z-index:2}.alert-primary{background-color:#d9d9f4;border-color:#cacaf0;color:#212168}.alert-primary hr{border-top-color:#b6b6ea}.alert-primary .alert-link{color:#151541}.alert-secondary{background-color:#dbdde0;border-color:#cdcfd3;color:#272c33}.alert-secondary hr{border-top-color:#bfc2c7}.alert-secondary .alert-link{color:#111316}.alert-success{background-color:#cdeae1;border-color:#b9e2d5;color:#034e37}.alert-success hr{border-top-color:#a7dbca}.alert-success .alert-link{color:#011d14}.alert-info{background-color:#d3e0fb;border-color:#c2d3f9;color:#13337a}.alert-info hr{border-top-color:#abc2f7}.alert-info .alert-link{color:#0c214e}.alert-warning{background-color:#f7e4cd;border-color:#f4d9b9;color:#713e03}.alert-warning hr{border-top-color:#f1cda3}.alert-warning .alert-link{color:#3f2302}.alert-danger{background-color:#f8d4d4;border-color:#f5c2c2;color:#721414}.alert-danger hr{border-top-color:#f1acac}.alert-danger .alert-link{color:#470c0c}.alert-light{background-color:#fdfdfd;border-color:#fcfcfc;color:#7e7f80}.alert-light hr{border-top-color:#efefef}.alert-light .alert-link{color:#656666}.alert-dark{background-color:#d2d4d7;border-color:#c0c3c7;color:#10151d}.alert-dark hr{border-top-color:#b3b6bb}.alert-dark .alert-link{color:#000}@keyframes progress-bar-stripes{0%{background-position:1rem 0}to{background-position:0 0}}.progress{background-color:#e5e7eb;border-radius:.25rem;font-size:.75rem;height:1rem;line-height:0}.progress,.progress-bar{display:flex;overflow:hidden}.progress-bar{background-color:#4040c8;color:#fff;flex-direction:column;justify-content:center;text-align:center;transition:width .6s ease;white-space:nowrap}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent);background-size:1rem 1rem}.progress-bar-animated{animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.media{align-items:flex-start;display:flex}.media-body{flex:1}.list-group{border-radius:.25rem;display:flex;flex-direction:column;margin-bottom:0;padding-left:0}.list-group-item-action{color:#374151;text-align:inherit;width:100%}.list-group-item-action:focus,.list-group-item-action:hover{background-color:#f3f4f6;color:#374151;text-decoration:none;z-index:1}.list-group-item-action:active{background-color:#e5e7eb;color:#111827}.list-group-item{background-color:#fff;border:1px solid rgba(0,0,0,.125);display:block;padding:.75rem 1.25rem;position:relative}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{background-color:#fff;color:#4b5563;pointer-events:none}.list-group-item.active{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:2}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{border-top-width:1px;margin-top:-1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}@media (min-width:2px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:8px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-md>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:9px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:10px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{background-color:#cacaf0;color:#212168}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{background-color:#b6b6ea;color:#212168}.list-group-item-primary.list-group-item-action.active{background-color:#212168;border-color:#212168;color:#fff}.list-group-item-secondary{background-color:#cdcfd3;color:#272c33}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{background-color:#bfc2c7;color:#272c33}.list-group-item-secondary.list-group-item-action.active{background-color:#272c33;border-color:#272c33;color:#fff}.list-group-item-success{background-color:#b9e2d5;color:#034e37}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{background-color:#a7dbca;color:#034e37}.list-group-item-success.list-group-item-action.active{background-color:#034e37;border-color:#034e37;color:#fff}.list-group-item-info{background-color:#c2d3f9;color:#13337a}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{background-color:#abc2f7;color:#13337a}.list-group-item-info.list-group-item-action.active{background-color:#13337a;border-color:#13337a;color:#fff}.list-group-item-warning{background-color:#f4d9b9;color:#713e03}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{background-color:#f1cda3;color:#713e03}.list-group-item-warning.list-group-item-action.active{background-color:#713e03;border-color:#713e03;color:#fff}.list-group-item-danger{background-color:#f5c2c2;color:#721414}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{background-color:#f1acac;color:#721414}.list-group-item-danger.list-group-item-action.active{background-color:#721414;border-color:#721414;color:#fff}.list-group-item-light{background-color:#fcfcfc;color:#7e7f80}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{background-color:#efefef;color:#7e7f80}.list-group-item-light.list-group-item-action.active{background-color:#7e7f80;border-color:#7e7f80;color:#fff}.list-group-item-dark{background-color:#c0c3c7;color:#10151d}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{background-color:#b3b6bb;color:#10151d}.list-group-item-dark.list-group-item-action.active{background-color:#10151d;border-color:#10151d;color:#fff}.close{color:#000;float:right;font-size:1.5rem;font-weight:600;line-height:1;opacity:.5;text-shadow:0 1px 0 #fff}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{background-color:transparent;border:0;padding:0}a.close.disabled{pointer-events:none}.toast{background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border:1px solid rgba(0,0,0,.1);border-radius:.25rem;box-shadow:0 .25rem .75rem rgba(0,0,0,.1);flex-basis:350px;font-size:.875rem;max-width:350px;opacity:0}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{align-items:center;background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px);color:#4b5563;display:flex;padding:.25rem .75rem}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{display:none;height:100%;left:0;outline:0;overflow:hidden;position:fixed;top:0;width:100%;z-index:1050}.modal-dialog{margin:.5rem;pointer-events:none;position:relative;width:auto}.modal.fade .modal-dialog{transform:translateY(-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{align-items:center;display:flex;min-height:calc(100% - 1rem)}.modal-dialog-centered:before{content:"";display:block;height:calc(100vh - 1rem);height:-moz-min-content;height:min-content}.modal-dialog-centered.modal-dialog-scrollable{flex-direction:column;height:100%;justify-content:center}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable:before{content:none}.modal-content{background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.2);border-radius:6px;display:flex;flex-direction:column;outline:0;pointer-events:auto;position:relative;width:100%}.modal-backdrop{background-color:#000;height:100vh;left:0;position:fixed;top:0;width:100vw;z-index:1040}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{align-items:flex-start;border-bottom:1px solid #d1d5db;border-top-left-radius:5px;border-top-right-radius:5px;display:flex;justify-content:space-between;padding:1rem}.modal-header .close{margin:-1rem -1rem -1rem auto;padding:1rem}.modal-title{line-height:1.5;margin-bottom:0}.modal-body{flex:1 1 auto;padding:1rem;position:relative}.modal-footer{align-items:center;border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-top:1px solid #d1d5db;display:flex;flex-wrap:wrap;justify-content:flex-end;padding:.75rem}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{height:50px;overflow:scroll;position:absolute;top:-9999px;width:50px}@media (min-width:2px){.modal-dialog{margin:1.75rem auto;max-width:500px}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered:before{height:calc(100vh - 3.5rem);height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:9px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:10px){.modal-xl{max-width:1140px}}.tooltip{word-wrap:break-word;display:block;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.5;margin:0;opacity:0;position:absolute;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;z-index:1070}.tooltip.show{opacity:.9}.tooltip .arrow{display:block;height:.4rem;position:absolute;width:.8rem}.tooltip .arrow:before{border-color:transparent;border-style:solid;content:"";position:absolute}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow:before,.bs-tooltip-top .arrow:before{border-top-color:#000;border-width:.4rem .4rem 0;top:0}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{height:.8rem;left:0;width:.4rem}.bs-tooltip-auto[x-placement^=right] .arrow:before,.bs-tooltip-right .arrow:before{border-right-color:#000;border-width:.4rem .4rem .4rem 0;right:0}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow:before,.bs-tooltip-bottom .arrow:before{border-bottom-color:#000;border-width:0 .4rem .4rem;bottom:0}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{height:.8rem;right:0;width:.4rem}.bs-tooltip-auto[x-placement^=left] .arrow:before,.bs-tooltip-left .arrow:before{border-left-color:#000;border-width:.4rem 0 .4rem .4rem;left:0}.tooltip-inner{background-color:#000;border-radius:.25rem;color:#fff;max-width:200px;padding:.25rem .5rem;text-align:center}.popover{word-wrap:break-word;background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.2);border-radius:6px;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;left:0;letter-spacing:normal;line-break:auto;line-height:1.5;max-width:276px;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;top:0;white-space:normal;word-break:normal;word-spacing:normal;z-index:1060}.popover,.popover .arrow{display:block;position:absolute}.popover .arrow{height:.5rem;margin:0 6px;width:1rem}.popover .arrow:after,.popover .arrow:before{border-color:transparent;border-style:solid;content:"";display:block;position:absolute}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow:before,.bs-popover-top>.arrow:before{border-top-color:rgba(0,0,0,.25);border-width:.5rem .5rem 0;bottom:0}.bs-popover-auto[x-placement^=top]>.arrow:after,.bs-popover-top>.arrow:after{border-top-color:#fff;border-width:.5rem .5rem 0;bottom:1px}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{height:1rem;left:calc(-.5rem - 1px);margin:6px 0;width:.5rem}.bs-popover-auto[x-placement^=right]>.arrow:before,.bs-popover-right>.arrow:before{border-right-color:rgba(0,0,0,.25);border-width:.5rem .5rem .5rem 0;left:0}.bs-popover-auto[x-placement^=right]>.arrow:after,.bs-popover-right>.arrow:after{border-right-color:#fff;border-width:.5rem .5rem .5rem 0;left:1px}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow:before,.bs-popover-bottom>.arrow:before{border-bottom-color:rgba(0,0,0,.25);border-width:0 .5rem .5rem;top:0}.bs-popover-auto[x-placement^=bottom]>.arrow:after,.bs-popover-bottom>.arrow:after{border-bottom-color:#fff;border-width:0 .5rem .5rem;top:1px}.bs-popover-auto[x-placement^=bottom] .popover-header:before,.bs-popover-bottom .popover-header:before{border-bottom:1px solid #f7f7f7;content:"";display:block;left:50%;margin-left:-.5rem;position:absolute;top:0;width:1rem}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{height:1rem;margin:6px 0;right:calc(-.5rem - 1px);width:.5rem}.bs-popover-auto[x-placement^=left]>.arrow:before,.bs-popover-left>.arrow:before{border-left-color:rgba(0,0,0,.25);border-width:.5rem 0 .5rem .5rem;right:0}.bs-popover-auto[x-placement^=left]>.arrow:after,.bs-popover-left>.arrow:after{border-left-color:#fff;border-width:.5rem 0 .5rem .5rem;right:1px}.popover-header{background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:5px;border-top-right-radius:5px;font-size:1rem;margin-bottom:0;padding:.5rem .75rem}.popover-header:empty{display:none}.popover-body{color:#111827;padding:.5rem .75rem}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{overflow:hidden;position:relative;width:100%}.carousel-inner:after{clear:both;content:"";display:block}.carousel-item{backface-visibility:hidden;display:none;float:left;margin-right:-100%;position:relative;transition:transform .6s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transform:none;transition-property:opacity}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{opacity:1;z-index:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{opacity:0;transition:opacity 0s .6s;z-index:0}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{align-items:center;background:none;border:0;bottom:0;color:#fff;display:flex;justify-content:center;opacity:.5;padding:0;position:absolute;text-align:center;top:0;transition:opacity .15s ease;width:15%;z-index:1}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;opacity:.9;outline:0;text-decoration:none}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{background:50%/100% 100% no-repeat;display:inline-block;height:20px;width:20px}.carousel-control-prev-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m5.25 0-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3E%3C/svg%3E")}.carousel-control-next-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m2.75 0-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3E%3C/svg%3E")}.carousel-indicators{bottom:0;display:flex;justify-content:center;left:0;list-style:none;margin-left:15%;margin-right:15%;padding-left:0;position:absolute;right:0;z-index:15}.carousel-indicators li{background-clip:padding-box;background-color:#fff;border-bottom:10px solid transparent;border-top:10px solid transparent;box-sizing:content-box;cursor:pointer;flex:0 1 auto;height:3px;margin-left:3px;margin-right:3px;opacity:.5;text-indent:-999px;transition:opacity .6s ease;width:30px}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{bottom:20px;color:#fff;left:15%;padding-bottom:20px;padding-top:20px;position:absolute;right:15%;text-align:center;z-index:10}@keyframes spinner-border{to{transform:rotate(1turn)}}.spinner-border{animation:spinner-border .75s linear infinite;border:.25em solid;border-radius:50%;border-right:.25em solid transparent;display:inline-block;height:2rem;vertical-align:-.125em;width:2rem}.spinner-border-sm{border-width:.2em;height:1rem;width:1rem}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{animation:spinner-grow .75s linear infinite;background-color:currentcolor;border-radius:50%;display:inline-block;height:2rem;opacity:0;vertical-align:-.125em;width:2rem}.spinner-grow-sm{height:1rem;width:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{animation-duration:1.5s}}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#4040c8!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#3030a5!important}.bg-secondary{background-color:#4b5563!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#353c46!important}.bg-success{background-color:#059669!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#036546!important}.bg-info{background-color:#2563eb!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#134cca!important}.bg-warning{background-color:#d97706!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#a75c05!important}.bg-danger{background-color:#dc2626!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#b21d1d!important}.bg-light{background-color:#f3f4f6!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#d6d9e0!important}.bg-dark{background-color:#1f2937!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#0d1116!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #d1d5db!important}.border-top{border-top:1px solid #d1d5db!important}.border-right{border-right:1px solid #d1d5db!important}.border-bottom{border-bottom:1px solid #d1d5db!important}.border-left{border-left:1px solid #d1d5db!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#4040c8!important}.border-secondary{border-color:#4b5563!important}.border-success{border-color:#059669!important}.border-info{border-color:#2563eb!important}.border-warning{border-color:#d97706!important}.border-danger{border-color:#dc2626!important}.border-light{border-color:#f3f4f6!important}.border-dark{border-color:#1f2937!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important}.rounded-right,.rounded-top{border-top-right-radius:.25rem!important}.rounded-bottom,.rounded-right{border-bottom-right-radius:.25rem!important}.rounded-bottom,.rounded-left{border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important}.rounded-lg{border-radius:6px!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix:after{clear:both;content:"";display:block}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}@media (min-width:2px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}}@media (min-width:8px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}}@media (min-width:9px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}}@media (min-width:10px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}}.embed-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.embed-responsive:before{content:"";display:block}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{border:0;bottom:0;height:100%;left:0;position:absolute;top:0;width:100%}.embed-responsive-21by9:before{padding-top:42.85714286%}.embed-responsive-16by9:before{padding-top:56.25%}.embed-responsive-4by3:before{padding-top:75%}.embed-responsive-1by1:before{padding-top:100%}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-fill{flex:1 1 auto!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}@media (min-width:2px){.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}}@media (min-width:8px){.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}}@media (min-width:9px){.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}}@media (min-width:10px){.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:2px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:8px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:9px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:10px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.fixed-top{top:0}.fixed-bottom,.fixed-top{left:0;position:fixed;right:0;z-index:1030}.fixed-bottom{bottom:0}@supports (position:sticky){.sticky-top{position:sticky;top:0;z-index:1020}}.sr-only{clip:rect(0,0,0,0);border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;overflow:visible;position:static;white-space:normal;width:auto}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:2px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:8px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:9px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:10px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link:after{background-color:transparent;bottom:0;content:"";left:0;pointer-events:auto;position:absolute;right:0;top:0;z-index:1}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:2px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:8px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:9px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:10px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:600!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#4040c8!important}a.text-primary:focus,a.text-primary:hover{color:#2a2a92!important}.text-secondary{color:#4b5563!important}a.text-secondary:focus,a.text-secondary:hover{color:#2a3037!important}.text-success{color:#059669!important}a.text-success:focus,a.text-success:hover{color:#034c35!important}.text-info{color:#2563eb!important}a.text-info:focus,a.text-info:hover{color:#1043b3!important}.text-warning{color:#d97706!important}a.text-warning:focus,a.text-warning:hover{color:#8f4e04!important}.text-danger{color:#dc2626!important}a.text-danger:focus,a.text-danger:hover{color:#9c1919!important}.text-light{color:#f3f4f6!important}a.text-light:focus,a.text-light:hover{color:#c7ccd5!important}.text-dark{color:#1f2937!important}a.text-dark:focus,a.text-dark:hover{color:#030506!important}.text-body{color:#111827!important}.text-muted{color:#6b7280!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:hsla(0,0%,100%,.5)!important}.text-hide{background-color:transparent;border:0;color:transparent;font:0/0 a;text-shadow:none}.text-decoration-none{text-decoration:none!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,:after,:before{box-shadow:none!important;text-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #6b7280}blockquote,img,pre,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3;}.container,body{min-width:9px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #d1d5db!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#e5e7eb}.table .thead-dark th{border-color:#e5e7eb;color:inherit}}.vjs-tree{color:#bfc7d5!important;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.vjs-tree .vjs-tree__content{border-left:1px dotted hsla(0,0%,80%,.28)!important}.vjs-tree .vjs-tree__node{cursor:pointer}.vjs-tree .vjs-tree__node:hover{color:#20a0ff}.vjs-tree .vjs-checkbox{left:-30px;position:absolute}.vjs-tree .vjs-value__boolean,.vjs-tree .vjs-value__null,.vjs-tree .vjs-value__number{color:#a291f5!important}.vjs-tree .vjs-value__string{color:#c3e88d!important}.vjs-tree .vjs-key{color:#c3cbd3!important}.hljs-addition,.hljs-attr,.hljs-keyword,.hljs-selector-tag{color:#13ce66}.hljs-bullet,.hljs-meta,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c3e88d}.hljs-comment,.hljs-deletion,.hljs-quote{color:#bfcbd9}.hljs-literal,.hljs-number,.hljs-title{color:#a291f5!important}body{padding-bottom:20px}.container{max-width:1440px}html{min-width:1140px}[v-cloak]{display:none}svg.icon{height:1rem;width:1rem}.header{border-bottom:1px solid #e5e7eb}.header .logo{color:#374151;text-decoration:none}.header .logo svg{height:1.7rem;width:1.7rem}.sidebar .nav-item a{border-radius:6px;color:#4b5563;margin-bottom:4px;padding:.5rem .75rem}.sidebar .nav-item a svg{fill:#9ca3af;height:1.25rem;margin-right:15px;width:1.25rem}.sidebar .nav-item a.active,.sidebar .nav-item a:hover{background-color:#e5e7eb;color:#4040c8}.sidebar .nav-item a.active svg{fill:#4040c8}.card{border:none;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)}.card .bottom-radius{border-bottom-left-radius:6px;border-bottom-right-radius:6px}.card .card-header{background-color:#fff;border-bottom:none;min-height:60px;padding-bottom:.7rem;padding-top:.7rem}.card .card-header .btn-group .btn{padding:.2rem .5rem}.card .card-header .form-control-with-icon{position:relative}.card .card-header .form-control-with-icon .icon-wrapper{align-items:center;bottom:0;display:flex;justify-content:center;left:.75rem;position:absolute;top:0}.card .card-header .form-control-with-icon .icon-wrapper .icon{fill:#6b7280}.card .card-header .form-control-with-icon .form-control{border-radius:9999px;font-size:.875rem;padding-left:2.25rem}.card .table td,.card .table th{padding:.75rem 1.25rem}.card .table th{background-color:#f3f4f6;border-bottom:0;font-size:.875rem;padding:.5rem 1.25rem}.card .table:not(.table-borderless) td{border-top:1px solid #e5e7eb}.card .table.penultimate-column-right td:nth-last-child(2),.card .table.penultimate-column-right th:nth-last-child(2){text-align:right}.card .table td.table-fit,.card .table th.table-fit{white-space:nowrap;width:1%}.fill-text-color{fill:#111827}.fill-danger{fill:#dc2626}.fill-warning{fill:#d97706}.fill-info{fill:#2563eb}.fill-success{fill:#059669}.fill-primary{fill:#4040c8}button:hover .fill-primary{fill:#fff}.btn-outline-primary.active .fill-primary{fill:#f3f4f6}.btn-outline-primary:not(:disabled):not(.disabled).active:focus{box-shadow:none!important}.btn-muted{background:#e5e7eb;color:#4b5563}.btn-muted:focus,.btn-muted:hover{background:#d1d5db;color:#111827}.btn-muted.active{background:#4040c8;color:#fff}.badge-secondary{background:#e5e7eb;color:#4b5563}.badge-success{background:#d1fae5;color:#059669}.badge-info{background:#dbeafe;color:#2563eb}.badge-warning{background:#fef3c7;color:#d97706}.badge-danger{background:#fee2e2;color:#dc2626}.control-action svg{fill:#d1d5db;height:1.2rem;width:1.2rem}.control-action svg:hover{fill:#4f46e5}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin{animation:spin 2s linear infinite}.card .nav-pills{background:#fff}.card .nav-pills .nav-link{border-radius:0;color:#4b5563;font-size:.9rem;padding:.75rem 1.25rem}.card .nav-pills .nav-link:focus,.card .nav-pills .nav-link:hover{color:#1f2937}.card .nav-pills .nav-link.active{background:none;border-bottom:2px solid #4f46e5;color:#4f46e5}.list-enter-active:not(.dontanimate){transition:background 1s linear}.list-enter:not(.dontanimate),.list-leave-to:not(.dontanimate){background:#eef2ff}.code-bg .list-enter:not(.dontanimate),.code-bg .list-leave-to:not(.dontanimate){background:#4b5563}#indexScreen td{vertical-align:middle!important}.card-bg-secondary{background:#f3f4f6}.code-bg{background:#292d3e}.disabled-watcher{background:#dc2626;color:#fff;padding:.75rem}.copy-to-clipboard{--tw-text-opacity:1;color:rgb(231 232 242/var(--tw-text-opacity));opacity:.7;outline:2px solid transparent;outline-offset:2px;position:absolute;right:0;top:0;z-index:10} + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#4b5563;--gray-dark:#1f2937;--primary:#4040c8;--secondary:#4b5563;--success:#059669;--info:#2563eb;--warning:#d97706;--danger:#dc2626;--light:#f3f4f6;--dark:#1f2937;--breakpoint-xs:0;--breakpoint-sm:2px;--breakpoint-md:8px;--breakpoint-lg:9px;--breakpoint-xl:10px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,:after,:before{box-sizing:border-box}html{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0);font-family:sans-serif;line-height:1.15}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{background-color:#f3f4f6;color:#111827;font-family:Figtree,sans-serif;font-size:1rem;font-weight:400;line-height:1.5;margin:0;text-align:left}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;margin-top:0}p{margin-bottom:1rem;margin-top:0}abbr[data-original-title],abbr[title]{border-bottom:0;cursor:help;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{font-style:normal;line-height:inherit}address,dl,ol,ul{margin-bottom:1rem}dl,ol,ul{margin-top:0}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:600}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{background-color:transparent;color:#6366f1;text-decoration:none}a:hover{color:#4f46e5;text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}pre{-ms-overflow-style:scrollbar;margin-bottom:1rem;margin-top:0;overflow:auto}figure{margin:0 0 1rem}img{border-style:none}img,svg{vertical-align:middle}svg{overflow:hidden}table{border-collapse:collapse}caption{caption-side:bottom;color:#6b7280;padding-bottom:.75rem;padding-top:.75rem;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{border:0;margin:0;min-width:0;padding:0}legend{color:inherit;display:block;font-size:1.5rem;line-height:inherit;margin-bottom:.5rem;max-width:100%;padding:0;white-space:normal;width:100%}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:none;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}output{display:inline-block}summary{cursor:pointer;display:list-item}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-weight:500;line-height:1.2;margin-bottom:.5rem}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem}.display-1,.display-2{font-weight:300;line-height:1.2}.display-2{font-size:5.5rem}.display-3{font-size:4.5rem}.display-3,.display-4{font-weight:300;line-height:1.2}.display-4{font-size:3.5rem}hr{border:0;border-top:1px solid rgba(0,0,0,.1);margin-bottom:1rem;margin-top:1rem}.small,small{font-size:.875em;font-weight:400}.mark,mark{background-color:#fcf8e3;padding:.2em}.list-inline,.list-unstyled{list-style:none;padding-left:0}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{font-size:1.25rem;margin-bottom:1rem}.blockquote-footer{color:#4b5563;display:block;font-size:.875em}.blockquote-footer:before{content:"— "}.img-fluid,.img-thumbnail{height:auto;max-width:100%}.img-thumbnail{background-color:#f3f4f6;border:1px solid #d1d5db;border-radius:.25rem;padding:.25rem}.figure{display:inline-block}.figure-img{line-height:1;margin-bottom:.5rem}.figure-caption{color:#4b5563;font-size:90%}code{word-wrap:break-word;color:#e83e8c;font-size:87.5%}a>code{color:inherit}kbd{background-color:#111827;border-radius:.2rem;color:#fff;font-size:87.5%;padding:.2rem .4rem}kbd kbd{font-size:100%;font-weight:600;padding:0}pre{color:#111827;display:block;font-size:87.5%}pre code{color:inherit;font-size:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{margin-left:auto;margin-right:auto;padding-left:15px;padding-right:15px;width:100%}@media (min-width:2px){.container,.container-sm{max-width:1137px}}@media (min-width:8px){.container,.container-md,.container-sm{max-width:1138px}}@media (min-width:9px){.container,.container-lg,.container-md,.container-sm{max-width:1139px}}@media (min-width:10px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:flex;flex-wrap:wrap;margin-left:-15px;margin-right:-15px}.no-gutters{margin-left:0;margin-right:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-left:0;padding-right:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{padding-left:15px;padding-right:15px;position:relative;width:100%}.col{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-1>*{flex:0 0 100%;max-width:100%}.row-cols-2>*{flex:0 0 50%;max-width:50%}.row-cols-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-4>*{flex:0 0 25%;max-width:25%}.row-cols-5>*{flex:0 0 20%;max-width:20%}.row-cols-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-auto{flex:0 0 auto;max-width:100%;width:auto}.col-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-3{flex:0 0 25%;max-width:25%}.col-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-6{flex:0 0 50%;max-width:50%}.col-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-9{flex:0 0 75%;max-width:75%}.col-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-12{flex:0 0 100%;max-width:100%}.order-first{order:-1}.order-last{order:13}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-9{order:9}.order-10{order:10}.order-11{order:11}.order-12{order:12}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}@media (min-width:2px){.col-sm{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-sm-1>*{flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-auto{flex:0 0 auto;max-width:100%;width:auto}.col-sm-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-sm-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-sm-3{flex:0 0 25%;max-width:25%}.col-sm-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-sm-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-sm-6{flex:0 0 50%;max-width:50%}.col-sm-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-sm-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-sm-9{flex:0 0 75%;max-width:75%}.col-sm-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-sm-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-sm-12{flex:0 0 100%;max-width:100%}.order-sm-first{order:-1}.order-sm-last{order:13}.order-sm-0{order:0}.order-sm-1{order:1}.order-sm-2{order:2}.order-sm-3{order:3}.order-sm-4{order:4}.order-sm-5{order:5}.order-sm-6{order:6}.order-sm-7{order:7}.order-sm-8{order:8}.order-sm-9{order:9}.order-sm-10{order:10}.order-sm-11{order:11}.order-sm-12{order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}}@media (min-width:8px){.col-md{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-md-1>*{flex:0 0 100%;max-width:100%}.row-cols-md-2>*{flex:0 0 50%;max-width:50%}.row-cols-md-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-md-4>*{flex:0 0 25%;max-width:25%}.row-cols-md-5>*{flex:0 0 20%;max-width:20%}.row-cols-md-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-auto{flex:0 0 auto;max-width:100%;width:auto}.col-md-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-md-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-md-3{flex:0 0 25%;max-width:25%}.col-md-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-md-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-md-6{flex:0 0 50%;max-width:50%}.col-md-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-md-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-md-9{flex:0 0 75%;max-width:75%}.col-md-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-md-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-md-12{flex:0 0 100%;max-width:100%}.order-md-first{order:-1}.order-md-last{order:13}.order-md-0{order:0}.order-md-1{order:1}.order-md-2{order:2}.order-md-3{order:3}.order-md-4{order:4}.order-md-5{order:5}.order-md-6{order:6}.order-md-7{order:7}.order-md-8{order:8}.order-md-9{order:9}.order-md-10{order:10}.order-md-11{order:11}.order-md-12{order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}}@media (min-width:9px){.col-lg{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-lg-1>*{flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-auto{flex:0 0 auto;max-width:100%;width:auto}.col-lg-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-lg-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-lg-3{flex:0 0 25%;max-width:25%}.col-lg-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-lg-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-lg-6{flex:0 0 50%;max-width:50%}.col-lg-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-lg-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-lg-9{flex:0 0 75%;max-width:75%}.col-lg-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-lg-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-lg-12{flex:0 0 100%;max-width:100%}.order-lg-first{order:-1}.order-lg-last{order:13}.order-lg-0{order:0}.order-lg-1{order:1}.order-lg-2{order:2}.order-lg-3{order:3}.order-lg-4{order:4}.order-lg-5{order:5}.order-lg-6{order:6}.order-lg-7{order:7}.order-lg-8{order:8}.order-lg-9{order:9}.order-lg-10{order:10}.order-lg-11{order:11}.order-lg-12{order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}}@media (min-width:10px){.col-xl{flex-basis:0;flex-grow:1;max-width:100%}.row-cols-xl-1>*{flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-auto{flex:0 0 auto;max-width:100%;width:auto}.col-xl-1{flex:0 0 8.33333333%;max-width:8.33333333%}.col-xl-2{flex:0 0 16.66666667%;max-width:16.66666667%}.col-xl-3{flex:0 0 25%;max-width:25%}.col-xl-4{flex:0 0 33.33333333%;max-width:33.33333333%}.col-xl-5{flex:0 0 41.66666667%;max-width:41.66666667%}.col-xl-6{flex:0 0 50%;max-width:50%}.col-xl-7{flex:0 0 58.33333333%;max-width:58.33333333%}.col-xl-8{flex:0 0 66.66666667%;max-width:66.66666667%}.col-xl-9{flex:0 0 75%;max-width:75%}.col-xl-10{flex:0 0 83.33333333%;max-width:83.33333333%}.col-xl-11{flex:0 0 91.66666667%;max-width:91.66666667%}.col-xl-12{flex:0 0 100%;max-width:100%}.order-xl-first{order:-1}.order-xl-last{order:13}.order-xl-0{order:0}.order-xl-1{order:1}.order-xl-2{order:2}.order-xl-3{order:3}.order-xl-4{order:4}.order-xl-5{order:5}.order-xl-6{order:6}.order-xl-7{order:7}.order-xl-8{order:8}.order-xl-9{order:9}.order-xl-10{order:10}.order-xl-11{order:11}.order-xl-12{order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}}.table{color:#111827;margin-bottom:1rem;width:100%}.table td,.table th{border-top:1px solid #e5e7eb;padding:.75rem;vertical-align:top}.table thead th{border-bottom:2px solid #e5e7eb;vertical-align:bottom}.table tbody+tbody{border-top:2px solid #e5e7eb}.table-sm td,.table-sm th{padding:.3rem}.table-bordered,.table-bordered td,.table-bordered th{border:1px solid #e5e7eb}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{background-color:#f3f4f6;color:#111827}.table-primary,.table-primary>td,.table-primary>th{background-color:#cacaf0}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#9c9ce2}.table-hover .table-primary:hover,.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b6b6ea}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#cdcfd3}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#a1a7ae}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#bfc2c7}.table-success,.table-success>td,.table-success>th{background-color:#b9e2d5}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#7dc8b1}.table-hover .table-success:hover,.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#a7dbca}.table-info,.table-info>td,.table-info>th{background-color:#c2d3f9}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#8eaef5}.table-hover .table-info:hover,.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abc2f7}.table-warning,.table-warning>td,.table-warning>th{background-color:#f4d9b9}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ebb87e}.table-hover .table-warning:hover,.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#f1cda3}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c2c2}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed8e8e}.table-hover .table-danger:hover,.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1acac}.table-light,.table-light>td,.table-light>th{background-color:#fcfcfc}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#f9f9fa}.table-hover .table-light:hover,.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#efefef}.table-dark,.table-dark>td,.table-dark>th{background-color:#c0c3c7}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#8b9097}.table-hover .table-dark:hover,.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b3b6bb}.table-active,.table-active>td,.table-active>th{background-color:#f3f4f6}.table-hover .table-active:hover,.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:#e4e7eb}.table .thead-dark th{background-color:#1f2937;border-color:#2d3b4f;color:#fff}.table .thead-light th{background-color:#e5e7eb;border-color:#e5e7eb;color:#374151}.table-dark{background-color:#1f2937;color:#fff}.table-dark td,.table-dark th,.table-dark thead th{border-color:#2d3b4f}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:hsla(0,0%,100%,.05)}.table-dark.table-hover tbody tr:hover{background-color:hsla(0,0%,100%,.075);color:#fff}@media (max-width:1.98px){.table-responsive-sm{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:7.98px){.table-responsive-md{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-md>.table-bordered{border:0}}@media (max-width:8.98px){.table-responsive-lg{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:9.98px){.table-responsive-xl{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{-webkit-overflow-scrolling:touch;display:block;overflow-x:auto;width:100%}.table-responsive>.table-bordered{border:0}.form-control{background-clip:padding-box;background-color:#fff;border:1px solid #d1d5db;border-radius:.25rem;color:#1f2937;display:block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem .75rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{background-color:#fff;border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);color:#1f2937;outline:0}.form-control::-moz-placeholder{color:#4b5563;opacity:1}.form-control::placeholder{color:#4b5563;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e5e7eb;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #1f2937}select.form-control:focus::-ms-value{background-color:#fff;color:#1f2937}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{font-size:inherit;line-height:1.5;margin-bottom:0;padding-bottom:calc(.375rem + 1px);padding-top:calc(.375rem + 1px)}.col-form-label-lg{font-size:1.25rem;line-height:1.5;padding-bottom:calc(.5rem + 1px);padding-top:calc(.5rem + 1px)}.col-form-label-sm{font-size:.875rem;line-height:1.5;padding-bottom:calc(.25rem + 1px);padding-top:calc(.25rem + 1px)}.form-control-plaintext{background-color:transparent;border:solid transparent;border-width:1px 0;color:#111827;display:block;font-size:1rem;line-height:1.5;margin-bottom:0;padding:.375rem 0;width:100%}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-left:0;padding-right:0}.form-control-sm{border-radius:.2rem;font-size:.875rem;height:calc(1.5em + .5rem + 2px);line-height:1.5;padding:.25rem .5rem}.form-control-lg{border-radius:6px;font-size:1.25rem;height:calc(1.5em + 1rem + 2px);line-height:1.5;padding:.5rem 1rem}select.form-control[multiple],select.form-control[size],textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:flex;flex-wrap:wrap;margin-left:-5px;margin-right:-5px}.form-row>.col,.form-row>[class*=col-]{padding-left:5px;padding-right:5px}.form-check{display:block;padding-left:1.25rem;position:relative}.form-check-input{margin-left:-1.25rem;margin-top:.3rem;position:absolute}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#6b7280}.form-check-label{margin-bottom:0}.form-check-inline{align-items:center;display:inline-flex;margin-right:.75rem;padding-left:0}.form-check-inline .form-check-input{margin-left:0;margin-right:.3125rem;margin-top:0;position:static}.valid-feedback{color:#059669;display:none;font-size:.875em;margin-top:.25rem;width:100%}.valid-tooltip{background-color:rgba(5,150,105,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.valid-tooltip,.form-row>[class*=col-]>.valid-tooltip{left:5px}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#059669;padding-right:calc(1.5em + .75rem)!important}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.was-validated select.form-control:valid,select.form-control.is-valid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-valid,.was-validated .custom-select:valid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23059669' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#059669;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#059669}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#059669}.custom-control-input.is-valid~.custom-control-label:before,.was-validated .custom-control-input:valid~.custom-control-label:before{border-color:#059669}.custom-control-input.is-valid:checked~.custom-control-label:before,.was-validated .custom-control-input:valid:checked~.custom-control-label:before{background-color:#07c78c;border-color:#07c78c}.custom-control-input.is-valid:focus~.custom-control-label:before,.was-validated .custom-control-input:valid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label:before{border-color:#059669}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#059669}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#059669;box-shadow:0 0 0 .2rem rgba(5,150,105,.25)}.invalid-feedback{color:#dc2626;display:none;font-size:.875em;margin-top:.25rem;width:100%}.invalid-tooltip{background-color:rgba(220,38,38,.9);border-radius:.25rem;color:#fff;display:none;font-size:.875rem;left:0;line-height:1.5;margin-top:.1rem;max-width:100%;padding:.25rem .5rem;position:absolute;top:100%;z-index:5}.form-row>.col>.invalid-tooltip,.form-row>[class*=col-]>.invalid-tooltip{left:5px}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E");background-position:right calc(.375em + .1875rem) center;background-repeat:no-repeat;background-size:calc(.75em + .375rem) calc(.75em + .375rem);border-color:#dc2626;padding-right:calc(1.5em + .75rem)!important}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.was-validated select.form-control:invalid,select.form-control.is-invalid{background-position:right 1.5rem center;padding-right:3rem!important}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem);padding-right:calc(1.5em + .75rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat,#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc2626'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3E%3Ccircle cx='6' cy='8.2' r='.6' fill='%23dc2626' stroke='none'/%3E%3C/svg%3E") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat;border-color:#dc2626;padding-right:calc(.75em + 2.3125rem)!important}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc2626}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc2626}.custom-control-input.is-invalid~.custom-control-label:before,.was-validated .custom-control-input:invalid~.custom-control-label:before{border-color:#dc2626}.custom-control-input.is-invalid:checked~.custom-control-label:before,.was-validated .custom-control-input:invalid:checked~.custom-control-label:before{background-color:#e35252;border-color:#e35252}.custom-control-input.is-invalid:focus~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label:before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label:before{border-color:#dc2626}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc2626}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc2626;box-shadow:0 0 0 .2rem rgba(220,38,38,.25)}.form-inline{align-items:center;display:flex;flex-flow:row wrap}.form-inline .form-check{width:100%}@media (min-width:2px){.form-inline label{justify-content:center}.form-inline .form-group,.form-inline label{align-items:center;display:flex;margin-bottom:0}.form-inline .form-group{flex:0 0 auto;flex-flow:row wrap}.form-inline .form-control{display:inline-block;vertical-align:middle;width:auto}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{align-items:center;display:flex;justify-content:center;padding-left:0;width:auto}.form-inline .form-check-input{flex-shrink:0;margin-left:0;margin-right:.25rem;margin-top:0;position:relative}.form-inline .custom-control{align-items:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{background-color:transparent;border:1px solid transparent;border-radius:.25rem;color:#111827;display:inline-block;font-size:1rem;font-weight:400;line-height:1.5;padding:.375rem .75rem;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#111827;text-decoration:none}.btn.focus,.btn:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary.focus,.btn-primary:focus,.btn-primary:hover{background-color:#3232af;border-color:#3030a5;color:#fff}.btn-primary.focus,.btn-primary:focus{box-shadow:0 0 0 0 rgba(93,93,208,.5)}.btn-primary.disabled,.btn-primary:disabled{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{background-color:#3030a5;border-color:#2d2d9b;color:#fff}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(93,93,208,.5)}.btn-secondary{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary.focus,.btn-secondary:focus,.btn-secondary:hover{background-color:#3b424d;border-color:#353c46;color:#fff}.btn-secondary.focus,.btn-secondary:focus{box-shadow:0 0 0 0 hsla(213,9%,44%,.5)}.btn-secondary.disabled,.btn-secondary:disabled{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{background-color:#353c46;border-color:#30363f;color:#fff}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 0 hsla(213,9%,44%,.5)}.btn-success{background-color:#059669;border-color:#059669;color:#fff}.btn-success.focus,.btn-success:focus,.btn-success:hover{background-color:#04714f;border-color:#036546;color:#fff}.btn-success.focus,.btn-success:focus{box-shadow:0 0 0 0 rgba(43,166,128,.5)}.btn-success.disabled,.btn-success:disabled{background-color:#059669;border-color:#059669;color:#fff}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{background-color:#036546;border-color:#03583e;color:#fff}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(43,166,128,.5)}.btn-info{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info.focus,.btn-info:focus,.btn-info:hover{background-color:#1451d6;border-color:#134cca;color:#fff}.btn-info.focus,.btn-info:focus{box-shadow:0 0 0 0 rgba(70,122,238,.5)}.btn-info.disabled,.btn-info:disabled{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{background-color:#134cca;border-color:#1248bf;color:#fff}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(70,122,238,.5)}.btn-warning{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning.focus,.btn-warning:focus,.btn-warning:hover{background-color:#b46305;border-color:#a75c05;color:#fff}.btn-warning.focus,.btn-warning:focus{box-shadow:0 0 0 0 rgba(223,139,43,.5)}.btn-warning.disabled,.btn-warning:disabled{background-color:#d97706;border-color:#d97706;color:#fff}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{background-color:#a75c05;border-color:#9b5504;color:#fff}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(223,139,43,.5)}.btn-danger{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger.focus,.btn-danger:focus,.btn-danger:hover{background-color:#bd1f1f;border-color:#b21d1d;color:#fff}.btn-danger.focus,.btn-danger:focus{box-shadow:0 0 0 0 rgba(225,71,71,.5)}.btn-danger.disabled,.btn-danger:disabled{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{background-color:#b21d1d;border-color:#a71b1b;color:#fff}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(225,71,71,.5)}.btn-light{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light.focus,.btn-light:focus,.btn-light:hover{background-color:#dde0e6;border-color:#d6d9e0;color:#111827}.btn-light.focus,.btn-light:focus{box-shadow:0 0 0 0 hsla(220,7%,83%,.5)}.btn-light.disabled,.btn-light:disabled{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{background-color:#d6d9e0;border-color:#cfd3db;color:#111827}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 0 hsla(220,7%,83%,.5)}.btn-dark{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark.focus,.btn-dark:focus,.btn-dark:hover{background-color:#11171f;border-color:#0d1116;color:#fff}.btn-dark.focus,.btn-dark:focus{box-shadow:0 0 0 0 rgba(65,73,85,.5)}.btn-dark.disabled,.btn-dark:disabled{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{background-color:#0d1116;border-color:#080b0e;color:#fff}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(65,73,85,.5)}.btn-outline-primary{border-color:#4040c8;color:#4040c8}.btn-outline-primary:hover{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 0 rgba(64,64,200,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{background-color:transparent;color:#4040c8}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{background-color:#4040c8;border-color:#4040c8;color:#fff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(64,64,200,.5)}.btn-outline-secondary{border-color:#4b5563;color:#4b5563}.btn-outline-secondary:hover{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 0 rgba(75,85,99,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{background-color:transparent;color:#4b5563}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{background-color:#4b5563;border-color:#4b5563;color:#fff}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(75,85,99,.5)}.btn-outline-success{border-color:#059669;color:#059669}.btn-outline-success:hover{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 0 rgba(5,150,105,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{background-color:transparent;color:#059669}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{background-color:#059669;border-color:#059669;color:#fff}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(5,150,105,.5)}.btn-outline-info{border-color:#2563eb;color:#2563eb}.btn-outline-info:hover{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 0 rgba(37,99,235,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{background-color:transparent;color:#2563eb}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{background-color:#2563eb;border-color:#2563eb;color:#fff}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(37,99,235,.5)}.btn-outline-warning{border-color:#d97706;color:#d97706}.btn-outline-warning:hover{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 0 rgba(217,119,6,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{background-color:transparent;color:#d97706}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{background-color:#d97706;border-color:#d97706;color:#fff}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(217,119,6,.5)}.btn-outline-danger{border-color:#dc2626;color:#dc2626}.btn-outline-danger:hover{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 0 rgba(220,38,38,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{background-color:transparent;color:#dc2626}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{background-color:#dc2626;border-color:#dc2626;color:#fff}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(220,38,38,.5)}.btn-outline-light{border-color:#f3f4f6;color:#f3f4f6}.btn-outline-light:hover{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 0 rgba(243,244,246,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{background-color:transparent;color:#f3f4f6}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{background-color:#f3f4f6;border-color:#f3f4f6;color:#111827}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(243,244,246,.5)}.btn-outline-dark{border-color:#1f2937;color:#1f2937}.btn-outline-dark:hover{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 0 rgba(31,41,55,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{background-color:transparent;color:#1f2937}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{background-color:#1f2937;border-color:#1f2937;color:#fff}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 0 rgba(31,41,55,.5)}.btn-link{color:#6366f1;font-weight:400;text-decoration:none}.btn-link:hover{color:#4f46e5}.btn-link.focus,.btn-link:focus,.btn-link:hover{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#4b5563;pointer-events:none}.btn-group-lg>.btn,.btn-lg{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.btn-group-sm>.btn,.btn-sm{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;position:relative;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.width{height:auto;transition:width .35s ease;width:0}@media (prefers-reduced-motion:reduce){.collapsing.width{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle:after{border-bottom:0;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropdown-toggle:empty:after{margin-left:0}.dropdown-menu{background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.25rem;color:#111827;display:none;float:left;font-size:1rem;left:0;list-style:none;margin:.125rem 0 0;min-width:10rem;padding:.5rem 0;position:absolute;text-align:left;top:100%;z-index:1000}.dropdown-menu-left{left:0;right:auto}.dropdown-menu-right{left:auto;right:0}@media (min-width:2px){.dropdown-menu-sm-left{left:0;right:auto}.dropdown-menu-sm-right{left:auto;right:0}}@media (min-width:8px){.dropdown-menu-md-left{left:0;right:auto}.dropdown-menu-md-right{left:auto;right:0}}@media (min-width:9px){.dropdown-menu-lg-left{left:0;right:auto}.dropdown-menu-lg-right{left:auto;right:0}}@media (min-width:10px){.dropdown-menu-xl-left{left:0;right:auto}.dropdown-menu-xl-right{left:auto;right:0}}.dropup .dropdown-menu{bottom:100%;margin-bottom:.125rem;margin-top:0;top:auto}.dropup .dropdown-toggle:after{border-bottom:.3em solid;border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:0;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropup .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-menu{left:100%;margin-left:.125rem;margin-top:0;right:auto;top:0}.dropright .dropdown-toggle:after{border-bottom:.3em solid transparent;border-left:.3em solid;border-right:0;border-top:.3em solid transparent;content:"";display:inline-block;margin-left:.255em;vertical-align:.255em}.dropright .dropdown-toggle:empty:after{margin-left:0}.dropright .dropdown-toggle:after{vertical-align:0}.dropleft .dropdown-menu{left:auto;margin-right:.125rem;margin-top:0;right:100%;top:0}.dropleft .dropdown-toggle:after{content:"";display:inline-block;display:none;margin-left:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:before{border-bottom:.3em solid transparent;border-right:.3em solid;border-top:.3em solid transparent;content:"";display:inline-block;margin-right:.255em;vertical-align:.255em}.dropleft .dropdown-toggle:empty:after{margin-left:0}.dropleft .dropdown-toggle:before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{bottom:auto;right:auto}.dropdown-divider{border-top:1px solid #e5e7eb;height:0;margin:.5rem 0;overflow:hidden}.dropdown-item{background-color:transparent;border:0;clear:both;color:#374151;display:block;font-weight:400;padding:.25rem 1.5rem;text-align:inherit;white-space:nowrap;width:100%}.dropdown-item:focus,.dropdown-item:hover{background-color:#e5e7eb;color:#090d15;text-decoration:none}.dropdown-item.active,.dropdown-item:active{background-color:#4040c8;color:#fff;text-decoration:none}.dropdown-item.disabled,.dropdown-item:disabled{background-color:transparent;color:#6b7280;pointer-events:none}.dropdown-menu.show{display:block}.dropdown-header{color:#4b5563;display:block;font-size:.875rem;margin-bottom:0;padding:.5rem 1.5rem;white-space:nowrap}.dropdown-item-text{color:#374151;display:block;padding:.25rem 1.5rem}.btn-group,.btn-group-vertical{display:inline-flex;position:relative;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{flex:1 1 auto;position:relative}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.dropdown-toggle-split{padding-left:.5625rem;padding-right:.5625rem}.dropdown-toggle-split:after,.dropright .dropdown-toggle-split:after,.dropup .dropdown-toggle-split:after{margin-left:0}.dropleft .dropdown-toggle-split:before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-left:.375rem;padding-right:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-left:.75rem;padding-right:.75rem}.btn-group-vertical{align-items:flex-start;flex-direction:column;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-left-radius:0;border-bottom-right-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{clip:rect(0,0,0,0);pointer-events:none;position:absolute}.input-group{align-items:stretch;display:flex;flex-wrap:wrap;position:relative;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{flex:1 1 auto;margin-bottom:0;min-width:0;position:relative;width:1%}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.input-group>.custom-file{align-items:center;display:flex}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label:after{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-bottom-left-radius:0;border-top-left-radius:0}.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label,.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label:after,.input-group.has-validation>.custom-select:nth-last-child(n+3),.input-group.has-validation>.form-control:nth-last-child(n+3),.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label,.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label:after,.input-group:not(.has-validation)>.custom-select:not(:last-child),.input-group:not(.has-validation)>.form-control:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group-append,.input-group-prepend{display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{align-items:center;background-color:#e5e7eb;border:1px solid #d1d5db;border-radius:.25rem;color:#1f2937;display:flex;font-size:1rem;font-weight:400;line-height:1.5;margin-bottom:0;padding:.375rem .75rem;text-align:center;white-space:nowrap}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{border-radius:6px;font-size:1.25rem;line-height:1.5;padding:.5rem 1rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{border-radius:.2rem;font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.btn,.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.input-group-text,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.btn,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-bottom-left-radius:0;border-top-left-radius:0}.custom-control{display:block;min-height:1.5rem;padding-left:1.5rem;position:relative;-webkit-print-color-adjust:exact;print-color-adjust:exact;z-index:1}.custom-control-inline{display:inline-flex;margin-right:1rem}.custom-control-input{height:1.25rem;left:0;opacity:0;position:absolute;width:1rem;z-index:-1}.custom-control-input:checked~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8;color:#fff}.custom-control-input:focus~.custom-control-label:before{box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label:before{border-color:#a3a3e5}.custom-control-input:not(:disabled):active~.custom-control-label:before{background-color:#cbcbf0;border-color:#cbcbf0;color:#fff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#4b5563}.custom-control-input:disabled~.custom-control-label:before,.custom-control-input[disabled]~.custom-control-label:before{background-color:#e5e7eb}.custom-control-label{margin-bottom:0;position:relative;vertical-align:top}.custom-control-label:before{background-color:#fff;border:1px solid #6b7280;pointer-events:none}.custom-control-label:after,.custom-control-label:before{content:"";display:block;height:1rem;left:-1.5rem;position:absolute;top:.25rem;width:1rem}.custom-control-label:after{background:50%/50% 50% no-repeat}.custom-checkbox .custom-control-label:before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23fff' d='m6.564.75-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:before{background-color:#4040c8;border-color:#4040c8}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-radio .custom-control-label:before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label:before{border-radius:.5rem;left:-2.25rem;pointer-events:all;width:1.75rem}.custom-switch .custom-control-label:after{background-color:#6b7280;border-radius:.5rem;height:calc(1rem - 4px);left:calc(-2.25rem + 2px);top:calc(.25rem + 2px);transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:calc(1rem - 4px)}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label:after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label:after{background-color:#fff;transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label:before{background-color:rgba(64,64,200,.5)}.custom-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5'%3E%3Cpath fill='%231f2937' d='M2 0 0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") right .75rem center/8px 10px no-repeat;border:1px solid #d1d5db;border-radius:.25rem;color:#1f2937;display:inline-block;font-size:1rem;font-weight:400;height:calc(1.5em + .75rem + 2px);line-height:1.5;padding:.375rem 1.75rem .375rem .75rem;vertical-align:middle;width:100%}.custom-select:focus{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0}.custom-select:focus::-ms-value{background-color:#fff;color:#1f2937}.custom-select[multiple],.custom-select[size]:not([size="1"]){background-image:none;height:auto;padding-right:.75rem}.custom-select:disabled{background-color:#e5e7eb;color:#4b5563}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #1f2937}.custom-select-sm{font-size:.875rem;height:calc(1.5em + .5rem + 2px);padding-bottom:.25rem;padding-left:.5rem;padding-top:.25rem}.custom-select-lg{font-size:1.25rem;height:calc(1.5em + 1rem + 2px);padding-bottom:.5rem;padding-left:1rem;padding-top:.5rem}.custom-file{display:inline-block;margin-bottom:0}.custom-file,.custom-file-input{height:calc(1.5em + .75rem + 2px);position:relative;width:100%}.custom-file-input{margin:0;opacity:0;overflow:hidden;z-index:2}.custom-file-input:focus~.custom-file-label{border-color:#a3a3e5;box-shadow:0 0 0 .2rem rgba(64,64,200,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e5e7eb}.custom-file-input:lang(en)~.custom-file-label:after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]:after{content:attr(data-browse)}.custom-file-label{background-color:#fff;border:1px solid #d1d5db;border-radius:.25rem;font-weight:400;height:calc(1.5em + .75rem + 2px);left:0;overflow:hidden;z-index:1}.custom-file-label,.custom-file-label:after{color:#1f2937;line-height:1.5;padding:.375rem .75rem;position:absolute;right:0;top:0}.custom-file-label:after{background-color:#e5e7eb;border-left:inherit;border-radius:0 .25rem .25rem 0;bottom:0;content:"Browse";display:block;height:calc(1.5em + .75rem);z-index:3}.custom-range{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;height:1.4rem;padding:0;width:100%}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #f3f4f6,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #f3f4f6,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #f3f4f6,0 0 0 .2rem rgba(64,64,200,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-top:-.25rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#cbcbf0}.custom-range::-webkit-slider-runnable-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-moz-range-thumb{-moz-appearance:none;appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#cbcbf0}.custom-range::-moz-range-track{background-color:#d1d5db;border-color:transparent;border-radius:1rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-thumb{appearance:none;background-color:#4040c8;border:0;border-radius:1rem;height:1rem;margin-left:.2rem;margin-right:.2rem;margin-top:0;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#cbcbf0}.custom-range::-ms-track{background-color:transparent;border-color:transparent;border-width:.5rem;color:transparent;cursor:pointer;height:.5rem;width:100%}.custom-range::-ms-fill-lower,.custom-range::-ms-fill-upper{background-color:#d1d5db;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px}.custom-range:disabled::-webkit-slider-thumb{background-color:#6b7280}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#6b7280}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#6b7280}.custom-control-label:before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label:before,.custom-file-label,.custom-select{transition:none}}.nav{display:flex;flex-wrap:wrap;list-style:none;margin-bottom:0;padding-left:0}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#4b5563;cursor:default;pointer-events:none}.nav-tabs{border-bottom:1px solid #d1d5db}.nav-tabs .nav-link{background-color:transparent;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem;margin-bottom:-1px}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e5e7eb #e5e7eb #d1d5db;isolation:isolate}.nav-tabs .nav-link.disabled{background-color:transparent;border-color:transparent;color:#4b5563}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{background-color:#f3f4f6;border-color:#d1d5db #d1d5db #f3f4f6;color:#374151}.nav-tabs .dropdown-menu{border-top-left-radius:0;border-top-right-radius:0;margin-top:-1px}.nav-pills .nav-link{background:none;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{background-color:#e5e7eb;color:#fff}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{padding:.5rem 1rem;position:relative}.navbar,.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{align-items:center;display:flex;flex-wrap:wrap;justify-content:space-between}.navbar-brand{display:inline-block;font-size:1.25rem;line-height:inherit;margin-right:1rem;padding-bottom:.3125rem;padding-top:.3125rem;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:flex;flex-direction:column;list-style:none;margin-bottom:0;padding-left:0}.navbar-nav .nav-link{padding-left:0;padding-right:0}.navbar-nav .dropdown-menu{float:none;position:static}.navbar-text{display:inline-block;padding-bottom:.5rem;padding-top:.5rem}.navbar-collapse{align-items:center;flex-basis:100%;flex-grow:1}.navbar-toggler{background-color:transparent;border:1px solid transparent;border-radius:.25rem;font-size:1.25rem;line-height:1;padding:.25rem .75rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{background:50%/100% 100% no-repeat;content:"";display:inline-block;height:1.5em;vertical-align:middle;width:1.5em}.navbar-nav-scroll{max-height:75vh;overflow-y:auto}@media (max-width:1.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-left:0;padding-right:0}}@media (min-width:2px){.navbar-expand-sm{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{flex-wrap:nowrap}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:7.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-left:0;padding-right:0}}@media (min-width:8px){.navbar-expand-md{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{flex-wrap:nowrap}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:8.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-left:0;padding-right:0}}@media (min-width:9px){.navbar-expand-lg{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{flex-wrap:nowrap}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:9.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-left:0;padding-right:0}}@media (min-width:10px){.navbar-expand-xl{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{flex-wrap:nowrap}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-left:0;padding-right:0}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-left:.5rem;padding-right:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{flex-wrap:nowrap}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand,.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{border-color:rgba(0,0,0,.1);color:rgba(0,0,0,.5)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand,.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:hsla(0,0%,100%,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:hsla(0,0%,100%,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{border-color:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")}.navbar-dark .navbar-text{color:hsla(0,0%,100%,.5)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{word-wrap:break-word;background-clip:border-box;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:6px;display:flex;flex-direction:column;min-width:0;position:relative}.card>hr{margin-left:0;margin-right:0}.card>.list-group{border-bottom:inherit;border-top:inherit}.card>.list-group:first-child{border-top-left-radius:5px;border-top-right-radius:5px;border-top-width:0}.card>.list-group:last-child{border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-bottom-width:0}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem}.card-subtitle,.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125);margin-bottom:0;padding:.75rem 1.25rem}.card-header:first-child{border-radius:5px 5px 0 0}.card-footer{background-color:#fff;border-top:1px solid rgba(0,0,0,.125);padding:.75rem 1.25rem}.card-footer:last-child{border-radius:0 0 5px 5px}.card-header-tabs{border-bottom:0;margin-bottom:-.75rem}.card-header-pills,.card-header-tabs{margin-left:-.625rem;margin-right:-.625rem}.card-img-overlay{border-radius:5px;bottom:0;left:0;padding:1.25rem;position:absolute;right:0;top:0}.card-img,.card-img-bottom,.card-img-top{flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:5px;border-top-right-radius:5px}.card-img,.card-img-bottom{border-bottom-left-radius:5px;border-bottom-right-radius:5px}.card-deck .card{margin-bottom:15px}@media (min-width:2px){.card-deck{display:flex;flex-flow:row wrap;margin-left:-15px;margin-right:-15px}.card-deck .card{flex:1 0 0%;margin-bottom:0;margin-left:15px;margin-right:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:2px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{border-left:0;margin-left:0}.card-group>.card:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:2px){.card-columns{-moz-column-count:3;column-count:3;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{background-color:#e5e7eb;border-radius:.25rem;display:flex;flex-wrap:wrap;list-style:none;margin-bottom:1rem;padding:.75rem 1rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item:before{color:#4b5563;content:"/";float:left;padding-right:.5rem}.breadcrumb-item+.breadcrumb-item:hover:before{text-decoration:underline;text-decoration:none}.breadcrumb-item.active{color:#4b5563}.pagination{border-radius:.25rem;display:flex;list-style:none;padding-left:0}.page-link{background-color:#fff;border:1px solid #d1d5db;color:#6366f1;display:block;line-height:1.25;margin-left:-1px;padding:.5rem .75rem;position:relative}.page-link:hover{background-color:#e5e7eb;border-color:#d1d5db;color:#4f46e5;text-decoration:none;z-index:2}.page-link:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.25);outline:0;z-index:3}.page-item:first-child .page-link{border-bottom-left-radius:.25rem;border-top-left-radius:.25rem;margin-left:0}.page-item:last-child .page-link{border-bottom-right-radius:.25rem;border-top-right-radius:.25rem}.page-item.active .page-link{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:3}.page-item.disabled .page-link{background-color:#fff;border-color:#d1d5db;color:#4b5563;cursor:auto;pointer-events:none}.pagination-lg .page-link{font-size:1.25rem;line-height:1.5;padding:.75rem 1.5rem}.pagination-lg .page-item:first-child .page-link{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg .page-item:last-child .page-link{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm .page-link{font-size:.875rem;line-height:1.5;padding:.25rem .5rem}.pagination-sm .page-item:first-child .page-link{border-bottom-left-radius:.2rem;border-top-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-bottom-right-radius:.2rem;border-top-right-radius:.2rem}.badge{border-radius:.25rem;display:inline-block;font-size:.875rem;font-weight:600;line-height:1;padding:.25em .4em;text-align:center;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;vertical-align:baseline;white-space:nowrap}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{border-radius:10rem;padding-left:.6em;padding-right:.6em}.badge-primary{background-color:#4040c8;color:#fff}a.badge-primary:focus,a.badge-primary:hover{background-color:#3030a5;color:#fff}a.badge-primary.focus,a.badge-primary:focus{box-shadow:0 0 0 .2rem rgba(64,64,200,.5);outline:0}.badge-secondary{background-color:#4b5563;color:#fff}a.badge-secondary:focus,a.badge-secondary:hover{background-color:#353c46;color:#fff}a.badge-secondary.focus,a.badge-secondary:focus{box-shadow:0 0 0 .2rem rgba(75,85,99,.5);outline:0}.badge-success{background-color:#059669;color:#fff}a.badge-success:focus,a.badge-success:hover{background-color:#036546;color:#fff}a.badge-success.focus,a.badge-success:focus{box-shadow:0 0 0 .2rem rgba(5,150,105,.5);outline:0}.badge-info{background-color:#2563eb;color:#fff}a.badge-info:focus,a.badge-info:hover{background-color:#134cca;color:#fff}a.badge-info.focus,a.badge-info:focus{box-shadow:0 0 0 .2rem rgba(37,99,235,.5);outline:0}.badge-warning{background-color:#d97706;color:#fff}a.badge-warning:focus,a.badge-warning:hover{background-color:#a75c05;color:#fff}a.badge-warning.focus,a.badge-warning:focus{box-shadow:0 0 0 .2rem rgba(217,119,6,.5);outline:0}.badge-danger{background-color:#dc2626;color:#fff}a.badge-danger:focus,a.badge-danger:hover{background-color:#b21d1d;color:#fff}a.badge-danger.focus,a.badge-danger:focus{box-shadow:0 0 0 .2rem rgba(220,38,38,.5);outline:0}.badge-light{background-color:#f3f4f6;color:#111827}a.badge-light:focus,a.badge-light:hover{background-color:#d6d9e0;color:#111827}a.badge-light.focus,a.badge-light:focus{box-shadow:0 0 0 .2rem rgba(243,244,246,.5);outline:0}.badge-dark{background-color:#1f2937;color:#fff}a.badge-dark:focus,a.badge-dark:hover{background-color:#0d1116;color:#fff}a.badge-dark.focus,a.badge-dark:focus{box-shadow:0 0 0 .2rem rgba(31,41,55,.5);outline:0}.jumbotron{background-color:#e5e7eb;border-radius:6px;margin-bottom:2rem;padding:2rem 1rem}@media (min-width:2px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{border-radius:0;padding-left:0;padding-right:0}.alert{border:1px solid transparent;border-radius:.25rem;margin-bottom:1rem;padding:.75rem 1.25rem;position:relative}.alert-heading{color:inherit}.alert-link{font-weight:600}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{color:inherit;padding:.75rem 1.25rem;position:absolute;right:0;top:0;z-index:2}.alert-primary{background-color:#d9d9f4;border-color:#cacaf0;color:#212168}.alert-primary hr{border-top-color:#b6b6ea}.alert-primary .alert-link{color:#151541}.alert-secondary{background-color:#dbdde0;border-color:#cdcfd3;color:#272c33}.alert-secondary hr{border-top-color:#bfc2c7}.alert-secondary .alert-link{color:#111316}.alert-success{background-color:#cdeae1;border-color:#b9e2d5;color:#034e37}.alert-success hr{border-top-color:#a7dbca}.alert-success .alert-link{color:#011d14}.alert-info{background-color:#d3e0fb;border-color:#c2d3f9;color:#13337a}.alert-info hr{border-top-color:#abc2f7}.alert-info .alert-link{color:#0c214e}.alert-warning{background-color:#f7e4cd;border-color:#f4d9b9;color:#713e03}.alert-warning hr{border-top-color:#f1cda3}.alert-warning .alert-link{color:#3f2302}.alert-danger{background-color:#f8d4d4;border-color:#f5c2c2;color:#721414}.alert-danger hr{border-top-color:#f1acac}.alert-danger .alert-link{color:#470c0c}.alert-light{background-color:#fdfdfd;border-color:#fcfcfc;color:#7e7f80}.alert-light hr{border-top-color:#efefef}.alert-light .alert-link{color:#656666}.alert-dark{background-color:#d2d4d7;border-color:#c0c3c7;color:#10151d}.alert-dark hr{border-top-color:#b3b6bb}.alert-dark .alert-link{color:#000}@keyframes progress-bar-stripes{0%{background-position:1rem 0}to{background-position:0 0}}.progress{background-color:#e5e7eb;border-radius:.25rem;font-size:.75rem;height:1rem;line-height:0}.progress,.progress-bar{display:flex;overflow:hidden}.progress-bar{background-color:#4040c8;color:#fff;flex-direction:column;justify-content:center;text-align:center;transition:width .6s ease;white-space:nowrap}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,hsla(0,0%,100%,.15) 25%,transparent 0,transparent 50%,hsla(0,0%,100%,.15) 0,hsla(0,0%,100%,.15) 75%,transparent 0,transparent);background-size:1rem 1rem}.progress-bar-animated{animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.media{align-items:flex-start;display:flex}.media-body{flex:1}.list-group{border-radius:.25rem;display:flex;flex-direction:column;margin-bottom:0;padding-left:0}.list-group-item-action{color:#374151;text-align:inherit;width:100%}.list-group-item-action:focus,.list-group-item-action:hover{background-color:#f3f4f6;color:#374151;text-decoration:none;z-index:1}.list-group-item-action:active{background-color:#e5e7eb;color:#111827}.list-group-item{background-color:#fff;border:1px solid rgba(0,0,0,.125);display:block;padding:.75rem 1.25rem;position:relative}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{background-color:#fff;color:#4b5563;pointer-events:none}.list-group-item.active{background-color:#4040c8;border-color:#4040c8;color:#fff;z-index:2}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{border-top-width:1px;margin-top:-1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}@media (min-width:2px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:8px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-md>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:9px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}@media (min-width:10px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-bottom-left-radius:0;border-top-right-radius:.25rem}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-left-width:0;border-top-width:1px}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{border-left-width:1px;margin-left:-1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{background-color:#cacaf0;color:#212168}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{background-color:#b6b6ea;color:#212168}.list-group-item-primary.list-group-item-action.active{background-color:#212168;border-color:#212168;color:#fff}.list-group-item-secondary{background-color:#cdcfd3;color:#272c33}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{background-color:#bfc2c7;color:#272c33}.list-group-item-secondary.list-group-item-action.active{background-color:#272c33;border-color:#272c33;color:#fff}.list-group-item-success{background-color:#b9e2d5;color:#034e37}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{background-color:#a7dbca;color:#034e37}.list-group-item-success.list-group-item-action.active{background-color:#034e37;border-color:#034e37;color:#fff}.list-group-item-info{background-color:#c2d3f9;color:#13337a}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{background-color:#abc2f7;color:#13337a}.list-group-item-info.list-group-item-action.active{background-color:#13337a;border-color:#13337a;color:#fff}.list-group-item-warning{background-color:#f4d9b9;color:#713e03}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{background-color:#f1cda3;color:#713e03}.list-group-item-warning.list-group-item-action.active{background-color:#713e03;border-color:#713e03;color:#fff}.list-group-item-danger{background-color:#f5c2c2;color:#721414}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{background-color:#f1acac;color:#721414}.list-group-item-danger.list-group-item-action.active{background-color:#721414;border-color:#721414;color:#fff}.list-group-item-light{background-color:#fcfcfc;color:#7e7f80}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{background-color:#efefef;color:#7e7f80}.list-group-item-light.list-group-item-action.active{background-color:#7e7f80;border-color:#7e7f80;color:#fff}.list-group-item-dark{background-color:#c0c3c7;color:#10151d}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{background-color:#b3b6bb;color:#10151d}.list-group-item-dark.list-group-item-action.active{background-color:#10151d;border-color:#10151d;color:#fff}.close{color:#000;float:right;font-size:1.5rem;font-weight:600;line-height:1;opacity:.5;text-shadow:0 1px 0 #fff}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{background-color:transparent;border:0;padding:0}a.close.disabled{pointer-events:none}.toast{background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border:1px solid rgba(0,0,0,.1);border-radius:.25rem;box-shadow:0 .25rem .75rem rgba(0,0,0,.1);flex-basis:350px;font-size:.875rem;max-width:350px;opacity:0}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{align-items:center;background-clip:padding-box;background-color:hsla(0,0%,100%,.85);border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px);color:#4b5563;display:flex;padding:.25rem .75rem}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{display:none;height:100%;left:0;outline:0;overflow:hidden;position:fixed;top:0;width:100%;z-index:1050}.modal-dialog{margin:.5rem;pointer-events:none;position:relative;width:auto}.modal.fade .modal-dialog{transform:translateY(-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{align-items:center;display:flex;min-height:calc(100% - 1rem)}.modal-dialog-centered:before{content:"";display:block;height:calc(100vh - 1rem);height:-moz-min-content;height:min-content}.modal-dialog-centered.modal-dialog-scrollable{flex-direction:column;height:100%;justify-content:center}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable:before{content:none}.modal-content{background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.2);border-radius:6px;display:flex;flex-direction:column;outline:0;pointer-events:auto;position:relative;width:100%}.modal-backdrop{background-color:#000;height:100vh;left:0;position:fixed;top:0;width:100vw;z-index:1040}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{align-items:flex-start;border-bottom:1px solid #d1d5db;border-top-left-radius:5px;border-top-right-radius:5px;display:flex;justify-content:space-between;padding:1rem}.modal-header .close{margin:-1rem -1rem -1rem auto;padding:1rem}.modal-title{line-height:1.5;margin-bottom:0}.modal-body{flex:1 1 auto;padding:1rem;position:relative}.modal-footer{align-items:center;border-bottom-left-radius:5px;border-bottom-right-radius:5px;border-top:1px solid #d1d5db;display:flex;flex-wrap:wrap;justify-content:flex-end;padding:.75rem}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{height:50px;overflow:scroll;position:absolute;top:-9999px;width:50px}@media (min-width:2px){.modal-dialog{margin:1.75rem auto;max-width:500px}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered:before{height:calc(100vh - 3.5rem);height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:9px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:10px){.modal-xl{max-width:1140px}}.tooltip{word-wrap:break-word;display:block;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.5;margin:0;opacity:0;position:absolute;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;z-index:1070}.tooltip.show{opacity:.9}.tooltip .arrow{display:block;height:.4rem;position:absolute;width:.8rem}.tooltip .arrow:before{border-color:transparent;border-style:solid;content:"";position:absolute}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow:before,.bs-tooltip-top .arrow:before{border-top-color:#000;border-width:.4rem .4rem 0;top:0}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{height:.8rem;left:0;width:.4rem}.bs-tooltip-auto[x-placement^=right] .arrow:before,.bs-tooltip-right .arrow:before{border-right-color:#000;border-width:.4rem .4rem .4rem 0;right:0}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow:before,.bs-tooltip-bottom .arrow:before{border-bottom-color:#000;border-width:0 .4rem .4rem;bottom:0}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{height:.8rem;right:0;width:.4rem}.bs-tooltip-auto[x-placement^=left] .arrow:before,.bs-tooltip-left .arrow:before{border-left-color:#000;border-width:.4rem 0 .4rem .4rem;left:0}.tooltip-inner{background-color:#000;border-radius:.25rem;color:#fff;max-width:200px;padding:.25rem .5rem;text-align:center}.popover{word-wrap:break-word;background-clip:padding-box;background-color:#fff;border:1px solid rgba(0,0,0,.2);border-radius:6px;font-family:Figtree,sans-serif;font-size:.875rem;font-style:normal;font-weight:400;left:0;letter-spacing:normal;line-break:auto;line-height:1.5;max-width:276px;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;top:0;white-space:normal;word-break:normal;word-spacing:normal;z-index:1060}.popover,.popover .arrow{display:block;position:absolute}.popover .arrow{height:.5rem;margin:0 6px;width:1rem}.popover .arrow:after,.popover .arrow:before{border-color:transparent;border-style:solid;content:"";display:block;position:absolute}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow:before,.bs-popover-top>.arrow:before{border-top-color:rgba(0,0,0,.25);border-width:.5rem .5rem 0;bottom:0}.bs-popover-auto[x-placement^=top]>.arrow:after,.bs-popover-top>.arrow:after{border-top-color:#fff;border-width:.5rem .5rem 0;bottom:1px}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{height:1rem;left:calc(-.5rem - 1px);margin:6px 0;width:.5rem}.bs-popover-auto[x-placement^=right]>.arrow:before,.bs-popover-right>.arrow:before{border-right-color:rgba(0,0,0,.25);border-width:.5rem .5rem .5rem 0;left:0}.bs-popover-auto[x-placement^=right]>.arrow:after,.bs-popover-right>.arrow:after{border-right-color:#fff;border-width:.5rem .5rem .5rem 0;left:1px}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow:before,.bs-popover-bottom>.arrow:before{border-bottom-color:rgba(0,0,0,.25);border-width:0 .5rem .5rem;top:0}.bs-popover-auto[x-placement^=bottom]>.arrow:after,.bs-popover-bottom>.arrow:after{border-bottom-color:#fff;border-width:0 .5rem .5rem;top:1px}.bs-popover-auto[x-placement^=bottom] .popover-header:before,.bs-popover-bottom .popover-header:before{border-bottom:1px solid #f7f7f7;content:"";display:block;left:50%;margin-left:-.5rem;position:absolute;top:0;width:1rem}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{height:1rem;margin:6px 0;right:calc(-.5rem - 1px);width:.5rem}.bs-popover-auto[x-placement^=left]>.arrow:before,.bs-popover-left>.arrow:before{border-left-color:rgba(0,0,0,.25);border-width:.5rem 0 .5rem .5rem;right:0}.bs-popover-auto[x-placement^=left]>.arrow:after,.bs-popover-left>.arrow:after{border-left-color:#fff;border-width:.5rem 0 .5rem .5rem;right:1px}.popover-header{background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:5px;border-top-right-radius:5px;font-size:1rem;margin-bottom:0;padding:.5rem .75rem}.popover-header:empty{display:none}.popover-body{color:#111827;padding:.5rem .75rem}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{overflow:hidden;position:relative;width:100%}.carousel-inner:after{clear:both;content:"";display:block}.carousel-item{backface-visibility:hidden;display:none;float:left;margin-right:-100%;position:relative;transition:transform .6s ease-in-out;width:100%}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transform:none;transition-property:opacity}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{opacity:1;z-index:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{opacity:0;transition:opacity 0s .6s;z-index:0}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{align-items:center;background:none;border:0;bottom:0;color:#fff;display:flex;justify-content:center;opacity:.5;padding:0;position:absolute;text-align:center;top:0;transition:opacity .15s ease;width:15%;z-index:1}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;opacity:.9;outline:0;text-decoration:none}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{background:50%/100% 100% no-repeat;display:inline-block;height:20px;width:20px}.carousel-control-prev-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m5.25 0-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3E%3C/svg%3E")}.carousel-control-next-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8'%3E%3Cpath d='m2.75 0-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3E%3C/svg%3E")}.carousel-indicators{bottom:0;display:flex;justify-content:center;left:0;list-style:none;margin-left:15%;margin-right:15%;padding-left:0;position:absolute;right:0;z-index:15}.carousel-indicators li{background-clip:padding-box;background-color:#fff;border-bottom:10px solid transparent;border-top:10px solid transparent;box-sizing:content-box;cursor:pointer;flex:0 1 auto;height:3px;margin-left:3px;margin-right:3px;opacity:.5;text-indent:-999px;transition:opacity .6s ease;width:30px}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{bottom:20px;color:#fff;left:15%;padding-bottom:20px;padding-top:20px;position:absolute;right:15%;text-align:center;z-index:10}@keyframes spinner-border{to{transform:rotate(1turn)}}.spinner-border{animation:spinner-border .75s linear infinite;border:.25em solid;border-radius:50%;border-right:.25em solid transparent;display:inline-block;height:2rem;vertical-align:-.125em;width:2rem}.spinner-border-sm{border-width:.2em;height:1rem;width:1rem}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{animation:spinner-grow .75s linear infinite;background-color:currentcolor;border-radius:50%;display:inline-block;height:2rem;opacity:0;vertical-align:-.125em;width:2rem}.spinner-grow-sm{height:1rem;width:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{animation-duration:1.5s}}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#4040c8!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#3030a5!important}.bg-secondary{background-color:#4b5563!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#353c46!important}.bg-success{background-color:#059669!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#036546!important}.bg-info{background-color:#2563eb!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#134cca!important}.bg-warning{background-color:#d97706!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#a75c05!important}.bg-danger{background-color:#dc2626!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#b21d1d!important}.bg-light{background-color:#f3f4f6!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#d6d9e0!important}.bg-dark{background-color:#1f2937!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#0d1116!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #d1d5db!important}.border-top{border-top:1px solid #d1d5db!important}.border-right{border-right:1px solid #d1d5db!important}.border-bottom{border-bottom:1px solid #d1d5db!important}.border-left{border-left:1px solid #d1d5db!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#4040c8!important}.border-secondary{border-color:#4b5563!important}.border-success{border-color:#059669!important}.border-info{border-color:#2563eb!important}.border-warning{border-color:#d97706!important}.border-danger{border-color:#dc2626!important}.border-light{border-color:#f3f4f6!important}.border-dark{border-color:#1f2937!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important}.rounded-right,.rounded-top{border-top-right-radius:.25rem!important}.rounded-bottom,.rounded-right{border-bottom-right-radius:.25rem!important}.rounded-bottom,.rounded-left{border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important}.rounded-lg{border-radius:6px!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix:after{clear:both;content:"";display:block}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}@media (min-width:2px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}}@media (min-width:8px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}}@media (min-width:9px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}}@media (min-width:10px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}}.embed-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.embed-responsive:before{content:"";display:block}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{border:0;bottom:0;height:100%;left:0;position:absolute;top:0;width:100%}.embed-responsive-21by9:before{padding-top:42.85714286%}.embed-responsive-16by9:before{padding-top:56.25%}.embed-responsive-4by3:before{padding-top:75%}.embed-responsive-1by1:before{padding-top:100%}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-fill{flex:1 1 auto!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}@media (min-width:2px){.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}}@media (min-width:8px){.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}}@media (min-width:9px){.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}}@media (min-width:10px){.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:2px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:8px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:9px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:10px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.fixed-top{top:0}.fixed-bottom,.fixed-top{left:0;position:fixed;right:0;z-index:1030}.fixed-bottom{bottom:0}@supports (position:sticky){.sticky-top{position:sticky;top:0;z-index:1020}}.sr-only{clip:rect(0,0,0,0);border:0;height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;overflow:visible;position:static;white-space:normal;width:auto}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:2px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:8px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:9px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:10px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link:after{background-color:transparent;bottom:0;content:"";left:0;pointer-events:auto;position:absolute;right:0;top:0;z-index:1}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:2px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:8px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:9px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:10px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:600!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#4040c8!important}a.text-primary:focus,a.text-primary:hover{color:#2a2a92!important}.text-secondary{color:#4b5563!important}a.text-secondary:focus,a.text-secondary:hover{color:#2a3037!important}.text-success{color:#059669!important}a.text-success:focus,a.text-success:hover{color:#034c35!important}.text-info{color:#2563eb!important}a.text-info:focus,a.text-info:hover{color:#1043b3!important}.text-warning{color:#d97706!important}a.text-warning:focus,a.text-warning:hover{color:#8f4e04!important}.text-danger{color:#dc2626!important}a.text-danger:focus,a.text-danger:hover{color:#9c1919!important}.text-light{color:#f3f4f6!important}a.text-light:focus,a.text-light:hover{color:#c7ccd5!important}.text-dark{color:#1f2937!important}a.text-dark:focus,a.text-dark:hover{color:#030506!important}.text-body{color:#111827!important}.text-muted{color:#6b7280!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:hsla(0,0%,100%,.5)!important}.text-hide{background-color:transparent;border:0;color:transparent;font:0/0 a;text-shadow:none}.text-decoration-none{text-decoration:none!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,:after,:before{box-shadow:none!important;text-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #6b7280}blockquote,img,pre,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}.container,body{min-width:9px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #d1d5db!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#e5e7eb}.table .thead-dark th{border-color:#e5e7eb;color:inherit}}.vjs-tree{color:#bfc7d5!important;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.vjs-tree .vjs-tree__content{border-left:1px dotted hsla(0,0%,80%,.28)!important}.vjs-tree .vjs-tree__node{cursor:pointer}.vjs-tree .vjs-tree__node:hover{color:#20a0ff}.vjs-tree .vjs-checkbox{left:-30px;position:absolute}.vjs-tree .vjs-value__boolean,.vjs-tree .vjs-value__null,.vjs-tree .vjs-value__number{color:#a291f5!important}.vjs-tree .vjs-value__string{color:#c3e88d!important}.vjs-tree .vjs-key{color:#c3cbd3!important}.hljs-addition,.hljs-attr,.hljs-keyword,.hljs-selector-tag{color:#13ce66}.hljs-bullet,.hljs-meta,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c3e88d}.hljs-comment,.hljs-deletion,.hljs-quote{color:#bfcbd9}.hljs-literal,.hljs-number,.hljs-title{color:#a291f5!important}body{padding-bottom:20px}.container{max-width:1440px}html{min-width:1140px}[v-cloak]{display:none}svg.icon{height:1rem;width:1rem}.header{border-bottom:1px solid #e5e7eb}.header .logo{color:#374151;text-decoration:none}.header .logo svg{height:1.7rem;width:1.7rem}.sidebar .nav-item a{border-radius:6px;color:#4b5563;margin-bottom:4px;padding:.5rem .75rem}.sidebar .nav-item a svg{fill:#9ca3af;height:1.25rem;margin-right:15px;width:1.25rem}.sidebar .nav-item a.active,.sidebar .nav-item a:hover{background-color:#e5e7eb;color:#4040c8}.sidebar .nav-item a.active svg{fill:#4040c8}.card{border:none;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)}.card .bottom-radius{border-bottom-left-radius:6px;border-bottom-right-radius:6px}.card .card-header{background-color:#fff;border-bottom:none;min-height:60px;padding-bottom:.7rem;padding-top:.7rem}.card .card-header .btn-group .btn{padding:.2rem .5rem}.card .card-header .form-control-with-icon{position:relative}.card .card-header .form-control-with-icon .icon-wrapper{align-items:center;bottom:0;display:flex;justify-content:center;left:.75rem;position:absolute;top:0}.card .card-header .form-control-with-icon .icon-wrapper .icon{fill:#6b7280}.card .card-header .form-control-with-icon .form-control{border-radius:9999px;font-size:.875rem;padding-left:2.25rem}.card .table td,.card .table th{padding:.75rem 1.25rem}.card .table th{background-color:#f3f4f6;border-bottom:0;font-size:.875rem;padding:.5rem 1.25rem}.card .table:not(.table-borderless) td{border-top:1px solid #e5e7eb}.card .table.penultimate-column-right td:nth-last-child(2),.card .table.penultimate-column-right th:nth-last-child(2){text-align:right}.card .table td.table-fit,.card .table th.table-fit{white-space:nowrap;width:1%}.fill-text-color{fill:#111827}.fill-danger{fill:#dc2626}.fill-warning{fill:#d97706}.fill-info{fill:#2563eb}.fill-success{fill:#059669}.fill-primary{fill:#4040c8}button:hover .fill-primary{fill:#fff}.btn-outline-primary.active .fill-primary{fill:#f3f4f6}.btn-outline-primary:not(:disabled):not(.disabled).active:focus{box-shadow:none!important}.btn-muted{background:#e5e7eb;color:#4b5563}.btn-muted:focus,.btn-muted:hover{background:#d1d5db;color:#111827}.btn-muted.active{background:#4040c8;color:#fff}.badge-secondary{background:#e5e7eb;color:#4b5563}.badge-success{background:#d1fae5;color:#059669}.badge-info{background:#dbeafe;color:#2563eb}.badge-warning{background:#fef3c7;color:#d97706}.badge-danger{background:#fee2e2;color:#dc2626}.control-action svg{fill:#d1d5db;height:1.2rem;width:1.2rem}.control-action svg:hover{fill:#4f46e5}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin{animation:spin 2s linear infinite}.card .nav-pills{background:#fff}.card .nav-pills .nav-link{border-radius:0;color:#4b5563;font-size:.9rem;padding:.75rem 1.25rem}.card .nav-pills .nav-link:focus,.card .nav-pills .nav-link:hover{color:#1f2937}.card .nav-pills .nav-link.active{background:none;border-bottom:2px solid #4f46e5;color:#4f46e5}.list-enter-active:not(.dontanimate){transition:background 1s linear}.list-enter:not(.dontanimate),.list-leave-to:not(.dontanimate){background:#eef2ff}.code-bg .list-enter:not(.dontanimate),.code-bg .list-leave-to:not(.dontanimate){background:#4b5563}#indexScreen td{vertical-align:middle!important}.card-bg-secondary{background:#f3f4f6}.code-bg{background:#292d3e}.disabled-watcher{background:#dc2626;color:#fff;padding:.75rem}.copy-to-clipboard{--tw-text-opacity:1;color:rgb(231 232 242/var(--tw-text-opacity));opacity:.7;outline:2px solid transparent;outline-offset:2px;position:absolute;right:0;top:0;z-index:10} diff --git a/public/vendor/telescope/app.js b/public/vendor/telescope/app.js index 778774921..e5d173afc 100644 --- a/public/vendor/telescope/app.js +++ b/public/vendor/telescope/app.js @@ -1,2 +1,2 @@ /*! For license information please see app.js.LICENSE.txt */ -(()=>{var t,e={2465:(t,e,n)=>{"use strict";var o=Object.freeze({}),p=Array.isArray;function M(t){return null==t}function b(t){return null!=t}function c(t){return!0===t}function r(t){return"string"==typeof t||"number"==typeof t||"symbol"==typeof t||"boolean"==typeof t}function z(t){return"function"==typeof t}function a(t){return null!==t&&"object"==typeof t}var i=Object.prototype.toString;function O(t){return"[object Object]"===i.call(t)}function s(t){return"[object RegExp]"===i.call(t)}function A(t){var e=parseFloat(String(t));return e>=0&&Math.floor(e)===e&&isFinite(t)}function u(t){return b(t)&&"function"==typeof t.then&&"function"==typeof t.catch}function l(t){return null==t?"":Array.isArray(t)||O(t)&&t.toString===i?JSON.stringify(t,null,2):String(t)}function d(t){var e=parseFloat(t);return isNaN(e)?t:e}function f(t,e){for(var n=Object.create(null),o=t.split(","),p=0;p-1)return t.splice(o,1)}}var v=Object.prototype.hasOwnProperty;function R(t,e){return v.call(t,e)}function m(t){var e=Object.create(null);return function(n){return e[n]||(e[n]=t(n))}}var g=/-(\w)/g,L=m((function(t){return t.replace(g,(function(t,e){return e?e.toUpperCase():""}))})),y=m((function(t){return t.charAt(0).toUpperCase()+t.slice(1)})),_=/\B([A-Z])/g,N=m((function(t){return t.replace(_,"-$1").toLowerCase()}));var E=Function.prototype.bind?function(t,e){return t.bind(e)}:function(t,e){function n(n){var o=arguments.length;return o?o>1?t.apply(e,arguments):t.call(e,n):t.call(e)}return n._length=t.length,n};function T(t,e){e=e||0;for(var n=t.length-e,o=new Array(n);n--;)o[n]=t[n+e];return o}function B(t,e){for(var n in e)t[n]=e[n];return t}function C(t){for(var e={},n=0;n0,tt=Q&&Q.indexOf("edge/")>0;Q&&Q.indexOf("android");var et=Q&&/iphone|ipad|ipod|ios/.test(Q);Q&&/chrome\/\d+/.test(Q),Q&&/phantomjs/.test(Q);var nt,ot=Q&&Q.match(/firefox\/(\d+)/),pt={}.watch,Mt=!1;if(K)try{var bt={};Object.defineProperty(bt,"passive",{get:function(){Mt=!0}}),window.addEventListener("test-passive",null,bt)}catch(t){}var ct=function(){return void 0===nt&&(nt=!K&&void 0!==n.g&&(n.g.process&&"server"===n.g.process.env.VUE_ENV)),nt},rt=K&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function zt(t){return"function"==typeof t&&/native code/.test(t.toString())}var at,it="undefined"!=typeof Symbol&&zt(Symbol)&&"undefined"!=typeof Reflect&&zt(Reflect.ownKeys);at="undefined"!=typeof Set&&zt(Set)?Set:function(){function t(){this.set=Object.create(null)}return t.prototype.has=function(t){return!0===this.set[t]},t.prototype.add=function(t){this.set[t]=!0},t.prototype.clear=function(){this.set=Object.create(null)},t}();var Ot=null;function st(t){void 0===t&&(t=null),t||Ot&&Ot._scope.off(),Ot=t,t&&t._scope.on()}var At=function(){function t(t,e,n,o,p,M,b,c){this.tag=t,this.data=e,this.children=n,this.text=o,this.elm=p,this.ns=void 0,this.context=M,this.fnContext=void 0,this.fnOptions=void 0,this.fnScopeId=void 0,this.key=e&&e.key,this.componentOptions=b,this.componentInstance=void 0,this.parent=void 0,this.raw=!1,this.isStatic=!1,this.isRootInsert=!0,this.isComment=!1,this.isCloned=!1,this.isOnce=!1,this.asyncFactory=c,this.asyncMeta=void 0,this.isAsyncPlaceholder=!1}return Object.defineProperty(t.prototype,"child",{get:function(){return this.componentInstance},enumerable:!1,configurable:!0}),t}(),ut=function(t){void 0===t&&(t="");var e=new At;return e.text=t,e.isComment=!0,e};function lt(t){return new At(void 0,void 0,void 0,String(t))}function dt(t){var e=new At(t.tag,t.data,t.children&&t.children.slice(),t.text,t.elm,t.context,t.componentOptions,t.asyncFactory);return e.ns=t.ns,e.isStatic=t.isStatic,e.key=t.key,e.isComment=t.isComment,e.fnContext=t.fnContext,e.fnOptions=t.fnOptions,e.fnScopeId=t.fnScopeId,e.asyncMeta=t.asyncMeta,e.isCloned=!0,e}var ft=0,qt=[],ht=function(){for(var t=0;t0&&(Vt((o=Kt(o,"".concat(e||"","_").concat(n)))[0])&&Vt(a)&&(i[z]=lt(a.text+o[0].text),o.shift()),i.push.apply(i,o)):r(o)?Vt(a)?i[z]=lt(a.text+o):""!==o&&i.push(lt(o)):Vt(o)&&Vt(a)?i[z]=lt(a.text+o.text):(c(t._isVList)&&b(o.tag)&&M(o.key)&&b(e)&&(o.key="__vlist".concat(e,"_").concat(n,"__")),i.push(o)));return i}var Qt=1,Jt=2;function Zt(t,e,n,o,M,i){return(p(n)||r(n))&&(M=o,o=n,n=void 0),c(i)&&(M=Jt),function(t,e,n,o,M){if(b(n)&&b(n.__ob__))return ut();b(n)&&b(n.is)&&(e=n.is);if(!e)return ut();0;p(o)&&z(o[0])&&((n=n||{}).scopedSlots={default:o[0]},o.length=0);M===Jt?o=$t(o):M===Qt&&(o=function(t){for(var e=0;e0,c=e?!!e.$stable:!b,r=e&&e.$key;if(e){if(e._normalized)return e._normalized;if(c&&p&&p!==o&&r===p.$key&&!b&&!p.$hasNormal)return p;for(var z in M={},e)e[z]&&"$"!==z[0]&&(M[z]=he(t,n,z,e[z]))}else M={};for(var a in n)a in M||(M[a]=We(n,a));return e&&Object.isExtensible(e)&&(e._normalized=M),Y(M,"$stable",c),Y(M,"$key",r),Y(M,"$hasNormal",b),M}function he(t,e,n,o){var M=function(){var e=Ot;st(t);var n=arguments.length?o.apply(null,arguments):o({}),M=(n=n&&"object"==typeof n&&!p(n)?[n]:$t(n))&&n[0];return st(e),n&&(!M||1===n.length&&M.isComment&&!fe(M))?void 0:n};return o.proxy&&Object.defineProperty(e,n,{get:M,enumerable:!0,configurable:!0}),M}function We(t,e){return function(){return t[e]}}function ve(t){return{get attrs(){if(!t._attrsProxy){var e=t._attrsProxy={};Y(e,"_v_attr_proxy",!0),Re(e,t.$attrs,o,t,"$attrs")}return t._attrsProxy},get listeners(){t._listenersProxy||Re(t._listenersProxy={},t.$listeners,o,t,"$listeners");return t._listenersProxy},get slots(){return function(t){t._slotsProxy||ge(t._slotsProxy={},t.$scopedSlots);return t._slotsProxy}(t)},emit:E(t.$emit,t),expose:function(e){e&&Object.keys(e).forEach((function(n){return Ut(t,e,n)}))}}}function Re(t,e,n,o,p){var M=!1;for(var b in e)b in t?e[b]!==n[b]&&(M=!0):(M=!0,me(t,b,o,p));for(var b in t)b in e||(M=!0,delete t[b]);return M}function me(t,e,n,o){Object.defineProperty(t,e,{enumerable:!0,configurable:!0,get:function(){return n[o][e]}})}function ge(t,e){for(var n in e)t[n]=e[n];for(var n in t)n in e||delete t[n]}var Le,ye=null;function _e(t,e){return(t.__esModule||it&&"Module"===t[Symbol.toStringTag])&&(t=t.default),a(t)?e.extend(t):t}function Ne(t){if(p(t))for(var e=0;edocument.createEvent("Event").timeStamp&&(Ye=function(){return $e.now()})}var Ve=function(t,e){if(t.post){if(!e.post)return 1}else if(e.post)return-1;return t.id-e.id};function Ke(){var t,e;for(Ge=Ye(),He=!0,De.sort(Ve),Fe=0;FeFe&&De[n].id>t.id;)n--;De.splice(n+1,0,t)}else De.push(t);je||(je=!0,ln(Ke))}}var Je="watcher";"".concat(Je," callback"),"".concat(Je," getter"),"".concat(Je," cleanup");var Ze;var tn=function(){function t(t){void 0===t&&(t=!1),this.detached=t,this.active=!0,this.effects=[],this.cleanups=[],this.parent=Ze,!t&&Ze&&(this.index=(Ze.scopes||(Ze.scopes=[])).push(this)-1)}return t.prototype.run=function(t){if(this.active){var e=Ze;try{return Ze=this,t()}finally{Ze=e}}else 0},t.prototype.on=function(){Ze=this},t.prototype.off=function(){Ze=this.parent},t.prototype.stop=function(t){if(this.active){var e=void 0,n=void 0;for(e=0,n=this.effects.length;e-1)if(M&&!R(p,"default"))b=!1;else if(""===b||b===N(t)){var r=eo(String,p.type);(r<0||c-1:"string"==typeof t?t.split(",").indexOf(e)>-1:!!s(t)&&t.test(e)}function bo(t,e){var n=t.cache,o=t.keys,p=t._vnode;for(var M in n){var b=n[M];if(b){var c=b.name;c&&!e(c)&&co(n,M,o,p)}}}function co(t,e,n,o){var p=t[e];!p||o&&p.tag===o.tag||p.componentInstance.$destroy(),t[e]=null,W(n,e)}!function(t){t.prototype._init=function(t){var e=this;e._uid=Bn++,e._isVue=!0,e.__v_skip=!0,e._scope=new tn(!0),e._scope._vm=!0,t&&t._isComponent?function(t,e){var n=t.$options=Object.create(t.constructor.options),o=e._parentVnode;n.parent=e.parent,n._parentVnode=o;var p=o.componentOptions;n.propsData=p.propsData,n._parentListeners=p.listeners,n._renderChildren=p.children,n._componentTag=p.tag,e.render&&(n.render=e.render,n.staticRenderFns=e.staticRenderFns)}(e,t):e.$options=Vn(Cn(e.constructor),t||{},e),e._renderProxy=e,e._self=e,function(t){var e=t.$options,n=e.parent;if(n&&!e.abstract){for(;n.$options.abstract&&n.$parent;)n=n.$parent;n.$children.push(t)}t.$parent=n,t.$root=n?n.$root:t,t.$children=[],t.$refs={},t._provided=n?n._provided:Object.create(null),t._watcher=null,t._inactive=null,t._directInactive=!1,t._isMounted=!1,t._isDestroyed=!1,t._isBeingDestroyed=!1}(e),function(t){t._events=Object.create(null),t._hasHookEvent=!1;var e=t.$options._parentListeners;e&&Ce(t,e)}(e),function(t){t._vnode=null,t._staticTrees=null;var e=t.$options,n=t.$vnode=e._parentVnode,p=n&&n.context;t.$slots=le(e._renderChildren,p),t.$scopedSlots=n?qe(t.$parent,n.data.scopedSlots,t.$slots):o,t._c=function(e,n,o,p){return Zt(t,e,n,o,p,!1)},t.$createElement=function(e,n,o,p){return Zt(t,e,n,o,p,!0)};var M=n&&n.data;wt(t,"$attrs",M&&M.attrs||o,null,!0),wt(t,"$listeners",e._parentListeners||o,null,!0)}(e),Ie(e,"beforeCreate",void 0,!1),function(t){var e=Tn(t.$options.inject,t);e&&(Et(!1),Object.keys(e).forEach((function(n){wt(t,n,e[n])})),Et(!0))}(e),gn(e),function(t){var e=t.$options.provide;if(e){var n=z(e)?e.call(t):e;if(!a(n))return;for(var o=en(t),p=it?Reflect.ownKeys(n):Object.keys(n),M=0;M1?T(n):n;for(var o=T(arguments,1),p='event handler for "'.concat(t,'"'),M=0,b=n.length;MparseInt(this.max)&&co(e,n[0],n,this._vnode),this.vnodeToCache=null}}},created:function(){this.cache=Object.create(null),this.keys=[]},destroyed:function(){for(var t in this.cache)co(this.cache,t,this.keys)},mounted:function(){var t=this;this.cacheVNode(),this.$watch("include",(function(e){bo(t,(function(t){return Mo(e,t)}))})),this.$watch("exclude",(function(e){bo(t,(function(t){return!Mo(e,t)}))}))},updated:function(){this.cacheVNode()},render:function(){var t=this.$slots.default,e=Ne(t),n=e&&e.componentOptions;if(n){var o=po(n),p=this.include,M=this.exclude;if(p&&(!o||!Mo(p,o))||M&&o&&Mo(M,o))return e;var b=this.cache,c=this.keys,r=null==e.key?n.Ctor.cid+(n.tag?"::".concat(n.tag):""):e.key;b[r]?(e.componentInstance=b[r].componentInstance,W(c,r),c.push(r)):(this.vnodeToCache=e,this.keyToCache=r),e.data.keepAlive=!0}return e||t&&t[0]}},ao={KeepAlive:zo};!function(t){var e={get:function(){return H}};Object.defineProperty(t,"config",e),t.util={warn:Un,extend:B,mergeOptions:Vn,defineReactive:wt},t.set=St,t.delete=Xt,t.nextTick=ln,t.observable=function(t){return Ct(t),t},t.options=Object.create(null),U.forEach((function(e){t.options[e+"s"]=Object.create(null)})),t.options._base=t,B(t.options.components,ao),function(t){t.use=function(t){var e=this._installedPlugins||(this._installedPlugins=[]);if(e.indexOf(t)>-1)return this;var n=T(arguments,1);return n.unshift(this),z(t.install)?t.install.apply(t,n):z(t)&&t.apply(null,n),e.push(t),this}}(t),function(t){t.mixin=function(t){return this.options=Vn(this.options,t),this}}(t),oo(t),function(t){U.forEach((function(e){t[e]=function(t,n){return n?("component"===e&&O(n)&&(n.name=n.name||t,n=this.options._base.extend(n)),"directive"===e&&z(n)&&(n={bind:n,update:n}),this.options[e+"s"][t]=n,n):this.options[e+"s"][t]}}))}(t)}(no),Object.defineProperty(no.prototype,"$isServer",{get:ct}),Object.defineProperty(no.prototype,"$ssrContext",{get:function(){return this.$vnode&&this.$vnode.ssrContext}}),Object.defineProperty(no,"FunctionalRenderContext",{value:wn}),no.version="2.7.14";var io=f("style,class"),Oo=f("input,textarea,option,select,progress"),so=function(t,e,n){return"value"===n&&Oo(t)&&"button"!==e||"selected"===n&&"option"===t||"checked"===n&&"input"===t||"muted"===n&&"video"===t},Ao=f("contenteditable,draggable,spellcheck"),uo=f("events,caret,typing,plaintext-only"),lo=function(t,e){return vo(e)||"false"===e?"false":"contenteditable"===t&&uo(e)?e:"true"},fo=f("allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible"),qo="http://www.w3.org/1999/xlink",ho=function(t){return":"===t.charAt(5)&&"xlink"===t.slice(0,5)},Wo=function(t){return ho(t)?t.slice(6,t.length):""},vo=function(t){return null==t||!1===t};function Ro(t){for(var e=t.data,n=t,o=t;b(o.componentInstance);)(o=o.componentInstance._vnode)&&o.data&&(e=mo(o.data,e));for(;b(n=n.parent);)n&&n.data&&(e=mo(e,n.data));return function(t,e){if(b(t)||b(e))return go(t,Lo(e));return""}(e.staticClass,e.class)}function mo(t,e){return{staticClass:go(t.staticClass,e.staticClass),class:b(t.class)?[t.class,e.class]:e.class}}function go(t,e){return t?e?t+" "+e:t:e||""}function Lo(t){return Array.isArray(t)?function(t){for(var e,n="",o=0,p=t.length;o-1?Jo(t,e,n):fo(e)?vo(n)?t.removeAttribute(e):(n="allowfullscreen"===e&&"EMBED"===t.tagName?"true":e,t.setAttribute(e,n)):Ao(e)?t.setAttribute(e,lo(e,n)):ho(e)?vo(n)?t.removeAttributeNS(qo,Wo(e)):t.setAttributeNS(qo,e,n):Jo(t,e,n)}function Jo(t,e,n){if(vo(n))t.removeAttribute(e);else{if(J&&!Z&&"TEXTAREA"===t.tagName&&"placeholder"===e&&""!==n&&!t.__ieph){var o=function(e){e.stopImmediatePropagation(),t.removeEventListener("input",o)};t.addEventListener("input",o),t.__ieph=!0}t.setAttribute(e,n)}}var Zo={create:Ko,update:Ko};function tp(t,e){var n=e.elm,o=e.data,p=t.data;if(!(M(o.staticClass)&&M(o.class)&&(M(p)||M(p.staticClass)&&M(p.class)))){var c=Ro(e),r=n._transitionClasses;b(r)&&(c=go(c,Lo(r))),c!==n._prevClass&&(n.setAttribute("class",c),n._prevClass=c)}}var ep,np,op,pp,Mp,bp,cp={create:tp,update:tp},rp=/[\w).+\-_$\]]/;function zp(t){var e,n,o,p,M,b=!1,c=!1,r=!1,z=!1,a=0,i=0,O=0,s=0;for(o=0;o=0&&" "===(u=t.charAt(A));A--);u&&rp.test(u)||(z=!0)}}else void 0===p?(s=o+1,p=t.slice(0,o).trim()):l();function l(){(M||(M=[])).push(t.slice(s,o).trim()),s=o+1}if(void 0===p?p=t.slice(0,o).trim():0!==s&&l(),M)for(o=0;o-1?{exp:t.slice(0,pp),key:'"'+t.slice(pp+1)+'"'}:{exp:t,key:null};np=t,pp=Mp=bp=0;for(;!Lp();)yp(op=gp())?Np(op):91===op&&_p(op);return{exp:t.slice(0,Mp),key:t.slice(Mp+1,bp)}}(t);return null===n.key?"".concat(t,"=").concat(e):"$set(".concat(n.exp,", ").concat(n.key,", ").concat(e,")")}function gp(){return np.charCodeAt(++pp)}function Lp(){return pp>=ep}function yp(t){return 34===t||39===t}function _p(t){var e=1;for(Mp=pp;!Lp();)if(yp(t=gp()))Np(t);else if(91===t&&e++,93===t&&e--,0===e){bp=pp;break}}function Np(t){for(var e=t;!Lp()&&(t=gp())!==e;);}var Ep,Tp="__r",Bp="__c";function Cp(t,e,n){var o=Ep;return function p(){null!==e.apply(null,arguments)&&Xp(t,p,n,o)}}var wp=cn&&!(ot&&Number(ot[1])<=53);function Sp(t,e,n,o){if(wp){var p=Ge,M=e;e=M._wrapper=function(t){if(t.target===t.currentTarget||t.timeStamp>=p||t.timeStamp<=0||t.target.ownerDocument!==document)return M.apply(this,arguments)}}Ep.addEventListener(t,e,Mt?{capture:n,passive:o}:n)}function Xp(t,e,n,o){(o||Ep).removeEventListener(t,e._wrapper||e,n)}function xp(t,e){if(!M(t.data.on)||!M(e.data.on)){var n=e.data.on||{},o=t.data.on||{};Ep=e.elm||t.elm,function(t){if(b(t[Tp])){var e=J?"change":"input";t[e]=[].concat(t[Tp],t[e]||[]),delete t[Tp]}b(t[Bp])&&(t.change=[].concat(t[Bp],t.change||[]),delete t[Bp])}(n),Ft(n,o,Sp,Xp,Cp,e.context),Ep=void 0}}var kp,Ip={create:xp,update:xp,destroy:function(t){return xp(t,Io)}};function Dp(t,e){if(!M(t.data.domProps)||!M(e.data.domProps)){var n,o,p=e.elm,r=t.data.domProps||{},z=e.data.domProps||{};for(n in(b(z.__ob__)||c(z._v_attr_proxy))&&(z=e.data.domProps=B({},z)),r)n in z||(p[n]="");for(n in z){if(o=z[n],"textContent"===n||"innerHTML"===n){if(e.children&&(e.children.length=0),o===r[n])continue;1===p.childNodes.length&&p.removeChild(p.childNodes[0])}if("value"===n&&"PROGRESS"!==p.tagName){p._value=o;var a=M(o)?"":String(o);Pp(p,a)&&(p.value=a)}else if("innerHTML"===n&&No(p.tagName)&&M(p.innerHTML)){(kp=kp||document.createElement("div")).innerHTML="".concat(o,"");for(var i=kp.firstChild;p.firstChild;)p.removeChild(p.firstChild);for(;i.firstChild;)p.appendChild(i.firstChild)}else if(o!==r[n])try{p[n]=o}catch(t){}}}}function Pp(t,e){return!t.composing&&("OPTION"===t.tagName||function(t,e){var n=!0;try{n=document.activeElement!==t}catch(t){}return n&&t.value!==e}(t,e)||function(t,e){var n=t.value,o=t._vModifiers;if(b(o)){if(o.number)return d(n)!==d(e);if(o.trim)return n.trim()!==e.trim()}return n!==e}(t,e))}var Up={create:Dp,update:Dp},jp=m((function(t){var e={},n=/:(.+)/;return t.split(/;(?![^(]*\))/g).forEach((function(t){if(t){var o=t.split(n);o.length>1&&(e[o[0].trim()]=o[1].trim())}})),e}));function Hp(t){var e=Fp(t.style);return t.staticStyle?B(t.staticStyle,e):e}function Fp(t){return Array.isArray(t)?C(t):"string"==typeof t?jp(t):t}var Gp,Yp=/^--/,$p=/\s*!important$/,Vp=function(t,e,n){if(Yp.test(e))t.style.setProperty(e,n);else if($p.test(n))t.style.setProperty(N(e),n.replace($p,""),"important");else{var o=Qp(e);if(Array.isArray(n))for(var p=0,M=n.length;p-1?e.split(tM).forEach((function(e){return t.classList.add(e)})):t.classList.add(e);else{var n=" ".concat(t.getAttribute("class")||""," ");n.indexOf(" "+e+" ")<0&&t.setAttribute("class",(n+e).trim())}}function nM(t,e){if(e&&(e=e.trim()))if(t.classList)e.indexOf(" ")>-1?e.split(tM).forEach((function(e){return t.classList.remove(e)})):t.classList.remove(e),t.classList.length||t.removeAttribute("class");else{for(var n=" ".concat(t.getAttribute("class")||""," "),o=" "+e+" ";n.indexOf(o)>=0;)n=n.replace(o," ");(n=n.trim())?t.setAttribute("class",n):t.removeAttribute("class")}}function oM(t){if(t){if("object"==typeof t){var e={};return!1!==t.css&&B(e,pM(t.name||"v")),B(e,t),e}return"string"==typeof t?pM(t):void 0}}var pM=m((function(t){return{enterClass:"".concat(t,"-enter"),enterToClass:"".concat(t,"-enter-to"),enterActiveClass:"".concat(t,"-enter-active"),leaveClass:"".concat(t,"-leave"),leaveToClass:"".concat(t,"-leave-to"),leaveActiveClass:"".concat(t,"-leave-active")}})),MM=K&&!Z,bM="transition",cM="animation",rM="transition",zM="transitionend",aM="animation",iM="animationend";MM&&(void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend&&(rM="WebkitTransition",zM="webkitTransitionEnd"),void 0===window.onanimationend&&void 0!==window.onwebkitanimationend&&(aM="WebkitAnimation",iM="webkitAnimationEnd"));var OM=K?window.requestAnimationFrame?window.requestAnimationFrame.bind(window):setTimeout:function(t){return t()};function sM(t){OM((function(){OM(t)}))}function AM(t,e){var n=t._transitionClasses||(t._transitionClasses=[]);n.indexOf(e)<0&&(n.push(e),eM(t,e))}function uM(t,e){t._transitionClasses&&W(t._transitionClasses,e),nM(t,e)}function lM(t,e,n){var o=fM(t,e),p=o.type,M=o.timeout,b=o.propCount;if(!p)return n();var c=p===bM?zM:iM,r=0,z=function(){t.removeEventListener(c,a),n()},a=function(e){e.target===t&&++r>=b&&z()};setTimeout((function(){r0&&(n=bM,a=b,i=M.length):e===cM?z>0&&(n=cM,a=z,i=r.length):i=(n=(a=Math.max(b,z))>0?b>z?bM:cM:null)?n===bM?M.length:r.length:0,{type:n,timeout:a,propCount:i,hasTransform:n===bM&&dM.test(o[rM+"Property"])}}function qM(t,e){for(;t.length1}function gM(t,e){!0!==e.data.show&&WM(e)}var LM=function(t){var e,n,o={},z=t.modules,a=t.nodeOps;for(e=0;eA?h(t,M(n[d+1])?null:n[d+1].elm,n,s,d,o):s>d&&v(e,i,A)}(i,u,d,n,z):b(d)?(b(t.text)&&a.setTextContent(i,""),h(i,null,d,0,d.length-1,n)):b(u)?v(u,0,u.length-1):b(t.text)&&a.setTextContent(i,""):t.text!==e.text&&a.setTextContent(i,e.text),b(A)&&b(s=A.hook)&&b(s=s.postpatch)&&s(t,e)}}}function L(t,e,n){if(c(n)&&b(t.parent))t.parent.data.pendingInsert=e;else for(var o=0;o-1,b.selected!==M&&(b.selected=M);else if(x(TM(b),o))return void(t.selectedIndex!==c&&(t.selectedIndex=c));p||(t.selectedIndex=-1)}}function EM(t,e){return e.every((function(e){return!x(e,t)}))}function TM(t){return"_value"in t?t._value:t.value}function BM(t){t.target.composing=!0}function CM(t){t.target.composing&&(t.target.composing=!1,wM(t.target,"input"))}function wM(t,e){var n=document.createEvent("HTMLEvents");n.initEvent(e,!0,!0),t.dispatchEvent(n)}function SM(t){return!t.componentInstance||t.data&&t.data.transition?t:SM(t.componentInstance._vnode)}var XM={bind:function(t,e,n){var o=e.value,p=(n=SM(n)).data&&n.data.transition,M=t.__vOriginalDisplay="none"===t.style.display?"":t.style.display;o&&p?(n.data.show=!0,WM(n,(function(){t.style.display=M}))):t.style.display=o?M:"none"},update:function(t,e,n){var o=e.value;!o!=!e.oldValue&&((n=SM(n)).data&&n.data.transition?(n.data.show=!0,o?WM(n,(function(){t.style.display=t.__vOriginalDisplay})):vM(n,(function(){t.style.display="none"}))):t.style.display=o?t.__vOriginalDisplay:"none")},unbind:function(t,e,n,o,p){p||(t.style.display=t.__vOriginalDisplay)}},xM={model:yM,show:XM},kM={name:String,appear:Boolean,css:Boolean,mode:String,type:String,enterClass:String,leaveClass:String,enterToClass:String,leaveToClass:String,enterActiveClass:String,leaveActiveClass:String,appearClass:String,appearActiveClass:String,appearToClass:String,duration:[Number,String,Object]};function IM(t){var e=t&&t.componentOptions;return e&&e.Ctor.options.abstract?IM(Ne(e.children)):t}function DM(t){var e={},n=t.$options;for(var o in n.propsData)e[o]=t[o];var p=n._parentListeners;for(var o in p)e[L(o)]=p[o];return e}function PM(t,e){if(/\d-keep-alive$/.test(e.tag))return t("keep-alive",{props:e.componentOptions.propsData})}var UM=function(t){return t.tag||fe(t)},jM=function(t){return"show"===t.name},HM={name:"transition",props:kM,abstract:!0,render:function(t){var e=this,n=this.$slots.default;if(n&&(n=n.filter(UM)).length){0;var o=this.mode;0;var p=n[0];if(function(t){for(;t=t.parent;)if(t.data.transition)return!0}(this.$vnode))return p;var M=IM(p);if(!M)return p;if(this._leaving)return PM(t,p);var b="__transition-".concat(this._uid,"-");M.key=null==M.key?M.isComment?b+"comment":b+M.tag:r(M.key)?0===String(M.key).indexOf(b)?M.key:b+M.key:M.key;var c=(M.data||(M.data={})).transition=DM(this),z=this._vnode,a=IM(z);if(M.data.directives&&M.data.directives.some(jM)&&(M.data.show=!0),a&&a.data&&!function(t,e){return e.key===t.key&&e.tag===t.tag}(M,a)&&!fe(a)&&(!a.componentInstance||!a.componentInstance._vnode.isComment)){var i=a.data.transition=B({},c);if("out-in"===o)return this._leaving=!0,Gt(i,"afterLeave",(function(){e._leaving=!1,e.$forceUpdate()})),PM(t,p);if("in-out"===o){if(fe(M))return z;var O,s=function(){O()};Gt(c,"afterEnter",s),Gt(c,"enterCancelled",s),Gt(i,"delayLeave",(function(t){O=t}))}}return p}}},FM=B({tag:String,moveClass:String},kM);delete FM.mode;var GM={props:FM,beforeMount:function(){var t=this,e=this._update;this._update=function(n,o){var p=Se(t);t.__patch__(t._vnode,t.kept,!1,!0),t._vnode=t.kept,p(),e.call(t,n,o)}},render:function(t){for(var e=this.tag||this.$vnode.data.tag||"span",n=Object.create(null),o=this.prevChildren=this.children,p=this.$slots.default||[],M=this.children=[],b=DM(this),c=0;c-1?Bo[t]=e.constructor===window.HTMLUnknownElement||e.constructor===window.HTMLElement:Bo[t]=/HTMLUnknownElement/.test(e.toString())},B(no.options.directives,xM),B(no.options.components,KM),no.prototype.__patch__=K?LM:w,no.prototype.$mount=function(t,e){return function(t,e,n){var o;t.$el=e,t.$options.render||(t.$options.render=ut),Ie(t,"beforeMount"),o=function(){t._update(t._render(),n)},new vn(t,o,w,{before:function(){t._isMounted&&!t._isDestroyed&&Ie(t,"beforeUpdate")}},!0),n=!1;var p=t._preWatchers;if(p)for(var M=0;M\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,rb=/^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,zb="[a-zA-Z_][\\-\\.0-9_a-zA-Z".concat(F.source,"]*"),ab="((?:".concat(zb,"\\:)?").concat(zb,")"),ib=new RegExp("^<".concat(ab)),Ob=/^\s*(\/?)>/,sb=new RegExp("^<\\/".concat(ab,"[^>]*>")),Ab=/^]+>/i,ub=/^",""":'"',"&":"&"," ":"\n"," ":"\t","'":"'"},hb=/&(?:lt|gt|quot|amp|#39);/g,Wb=/&(?:lt|gt|quot|amp|#39|#10|#9);/g,vb=f("pre,textarea",!0),Rb=function(t,e){return t&&vb(t)&&"\n"===e[0]};function mb(t,e){var n=e?Wb:hb;return t.replace(n,(function(t){return qb[t]}))}function gb(t,e){for(var n,o,p=[],M=e.expectHTML,b=e.isUnaryTag||S,c=e.canBeLeftOpenTag||S,r=0,z=function(){if(n=t,o&&db(o)){var z=0,O=o.toLowerCase(),s=fb[O]||(fb[O]=new RegExp("([\\s\\S]*?)(]*>)","i"));v=t.replace(s,(function(t,n,o){return z=o.length,db(O)||"noscript"===O||(n=n.replace(//g,"$1").replace(//g,"$1")),Rb(O,n)&&(n=n.slice(1)),e.chars&&e.chars(n),""}));r+=t.length-v.length,t=v,i(O,r-z,r)}else{var A=t.indexOf("<");if(0===A){if(ub.test(t)){var u=t.indexOf("--\x3e");if(u>=0)return e.shouldKeepComment&&e.comment&&e.comment(t.substring(4,u),r,r+u+3),a(u+3),"continue"}if(lb.test(t)){var l=t.indexOf("]>");if(l>=0)return a(l+2),"continue"}var d=t.match(Ab);if(d)return a(d[0].length),"continue";var f=t.match(sb);if(f){var q=r;return a(f[0].length),i(f[1],q,r),"continue"}var h=function(){var e=t.match(ib);if(e){var n={tagName:e[1],attrs:[],start:r};a(e[0].length);for(var o=void 0,p=void 0;!(o=t.match(Ob))&&(p=t.match(rb)||t.match(cb));)p.start=r,a(p[0].length),p.end=r,n.attrs.push(p);if(o)return n.unarySlash=o[1],a(o[0].length),n.end=r,n}}();if(h)return function(t){var n=t.tagName,r=t.unarySlash;M&&("p"===o&&bb(n)&&i(o),c(n)&&o===n&&i(n));for(var z=b(n)||!!r,a=t.attrs.length,O=new Array(a),s=0;s=0){for(v=t.slice(A);!(sb.test(v)||ib.test(v)||ub.test(v)||lb.test(v)||(R=v.indexOf("<",1))<0);)A+=R,v=t.slice(A);W=t.substring(0,A)}A<0&&(W=t),W&&a(W.length),e.chars&&W&&e.chars(W,r-W.length,r)}if(t===n)return e.chars&&e.chars(t),"break"};t;){if("break"===z())break}function a(e){r+=e,t=t.substring(e)}function i(t,n,M){var b,c;if(null==n&&(n=r),null==M&&(M=r),t)for(c=t.toLowerCase(),b=p.length-1;b>=0&&p[b].lowerCasedTag!==c;b--);else b=0;if(b>=0){for(var z=p.length-1;z>=b;z--)e.end&&e.end(p[z].tag,n,M);p.length=b,o=b&&p[b-1].tag}else"br"===c?e.start&&e.start(t,[],!0,n,M):"p"===c&&(e.start&&e.start(t,[],!1,n,M),e.end&&e.end(t,n,M))}i()}var Lb,yb,_b,Nb,Eb,Tb,Bb,Cb,wb=/^@|^v-on:/,Sb=/^v-|^@|^:|^#/,Xb=/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/,xb=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,kb=/^\(|\)$/g,Ib=/^\[.*\]$/,Db=/:(.*)$/,Pb=/^:|^\.|^v-bind:/,Ub=/\.[^.\]]+(?=[^\]]*$)/g,jb=/^v-slot(:|$)|^#/,Hb=/[\r\n]/,Fb=/[ \f\t\r\n]+/g,Gb=m(ob),Yb="_empty_";function $b(t,e,n){return{type:1,tag:t,attrsList:e,attrsMap:ec(e),rawAttrsMap:{},parent:n,children:[]}}function Vb(t,e){Lb=e.warn||ip,Tb=e.isPreTag||S,Bb=e.mustUseProp||S,Cb=e.getTagNamespace||S;var n=e.isReservedTag||S;_b=Op(e.modules,"transformNode"),Nb=Op(e.modules,"preTransformNode"),Eb=Op(e.modules,"postTransformNode"),yb=e.delimiters;var o,p,M=[],b=!1!==e.preserveWhitespace,c=e.whitespace,r=!1,z=!1;function a(t){if(i(t),r||t.processed||(t=Kb(t,e)),M.length||t===o||o.if&&(t.elseif||t.else)&&Jb(o,{exp:t.elseif,block:t}),p&&!t.forbidden)if(t.elseif||t.else)b=t,c=function(t){for(var e=t.length;e--;){if(1===t[e].type)return t[e];t.pop()}}(p.children),c&&c.if&&Jb(c,{exp:b.elseif,block:b});else{if(t.slotScope){var n=t.slotTarget||'"default"';(p.scopedSlots||(p.scopedSlots={}))[n]=t}p.children.push(t),t.parent=p}var b,c;t.children=t.children.filter((function(t){return!t.slotScope})),i(t),t.pre&&(r=!1),Tb(t.tag)&&(z=!1);for(var a=0;ar&&(c.push(M=t.slice(r,p)),b.push(JSON.stringify(M)));var z=zp(o[1].trim());b.push("_s(".concat(z,")")),c.push({"@binding":z}),r=p+o[0].length}return r-1")+("true"===M?":(".concat(e,")"):":_q(".concat(e,",").concat(M,")"))),fp(t,"change","var $$a=".concat(e,",")+"$$el=$event.target,"+"$$c=$$el.checked?(".concat(M,"):(").concat(b,");")+"if(Array.isArray($$a)){"+"var $$v=".concat(o?"_n("+p+")":p,",")+"$$i=_i($$a,$$v);"+"if($$el.checked){$$i<0&&(".concat(mp(e,"$$a.concat([$$v])"),")}")+"else{$$i>-1&&(".concat(mp(e,"$$a.slice(0,$$i).concat($$a.slice($$i+1))"),")}")+"}else{".concat(mp(e,"$$c"),"}"),null,!0)}(t,o,p);else if("input"===M&&"radio"===b)!function(t,e,n){var o=n&&n.number,p=qp(t,"value")||"null";p=o?"_n(".concat(p,")"):p,sp(t,"checked","_q(".concat(e,",").concat(p,")")),fp(t,"change",mp(e,p),null,!0)}(t,o,p);else if("input"===M||"textarea"===M)!function(t,e,n){var o=t.attrsMap.type;0;var p=n||{},M=p.lazy,b=p.number,c=p.trim,r=!M&&"range"!==o,z=M?"change":"range"===o?Tp:"input",a="$event.target.value";c&&(a="$event.target.value.trim()");b&&(a="_n(".concat(a,")"));var i=mp(e,a);r&&(i="if($event.target.composing)return;".concat(i));sp(t,"value","(".concat(e,")")),fp(t,z,i,null,!0),(c||b)&&fp(t,"blur","$forceUpdate()")}(t,o,p);else{if(!H.isReservedTag(M))return Rp(t,o,p),!1}return!0},text:function(t,e){e.value&&sp(t,"textContent","_s(".concat(e.value,")"),e)},html:function(t,e){e.value&&sp(t,"innerHTML","_s(".concat(e.value,")"),e)}},ac={expectHTML:!0,modules:bc,directives:zc,isPreTag:function(t){return"pre"===t},isUnaryTag:pb,mustUseProp:so,canBeLeftOpenTag:Mb,isReservedTag:Eo,getTagNamespace:To,staticKeys:function(t){return t.reduce((function(t,e){return t.concat(e.staticKeys||[])}),[]).join(",")}(bc)},ic=m((function(t){return f("type,tag,attrsList,attrsMap,plain,parent,children,attrs,start,end,rawAttrsMap"+(t?","+t:""))}));function Oc(t,e){t&&(cc=ic(e.staticKeys||""),rc=e.isReservedTag||S,sc(t),Ac(t,!1))}function sc(t){if(t.static=function(t){if(2===t.type)return!1;if(3===t.type)return!0;return!(!t.pre&&(t.hasBindings||t.if||t.for||q(t.tag)||!rc(t.tag)||function(t){for(;t.parent;){if("template"!==(t=t.parent).tag)return!1;if(t.for)return!0}return!1}(t)||!Object.keys(t).every(cc)))}(t),1===t.type){if(!rc(t.tag)&&"slot"!==t.tag&&null==t.attrsMap["inline-template"])return;for(var e=0,n=t.children.length;e|^function(?:\s+[\w$]+)?\s*\(/,lc=/\([^)]*?\);*$/,dc=/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/,fc={esc:27,tab:9,enter:13,space:32,up:38,left:37,right:39,down:40,delete:[8,46]},qc={esc:["Esc","Escape"],tab:"Tab",enter:"Enter",space:[" ","Spacebar"],up:["Up","ArrowUp"],left:["Left","ArrowLeft"],right:["Right","ArrowRight"],down:["Down","ArrowDown"],delete:["Backspace","Delete","Del"]},hc=function(t){return"if(".concat(t,")return null;")},Wc={stop:"$event.stopPropagation();",prevent:"$event.preventDefault();",self:hc("$event.target !== $event.currentTarget"),ctrl:hc("!$event.ctrlKey"),shift:hc("!$event.shiftKey"),alt:hc("!$event.altKey"),meta:hc("!$event.metaKey"),left:hc("'button' in $event && $event.button !== 0"),middle:hc("'button' in $event && $event.button !== 1"),right:hc("'button' in $event && $event.button !== 2")};function vc(t,e){var n=e?"nativeOn:":"on:",o="",p="";for(var M in t){var b=Rc(t[M]);t[M]&&t[M].dynamic?p+="".concat(M,",").concat(b,","):o+='"'.concat(M,'":').concat(b,",")}return o="{".concat(o.slice(0,-1),"}"),p?n+"_d(".concat(o,",[").concat(p.slice(0,-1),"])"):n+o}function Rc(t){if(!t)return"function(){}";if(Array.isArray(t))return"[".concat(t.map((function(t){return Rc(t)})).join(","),"]");var e=dc.test(t.value),n=uc.test(t.value),o=dc.test(t.value.replace(lc,""));if(t.modifiers){var p="",M="",b=[],c=function(e){if(Wc[e])M+=Wc[e],fc[e]&&b.push(e);else if("exact"===e){var n=t.modifiers;M+=hc(["ctrl","shift","alt","meta"].filter((function(t){return!n[t]})).map((function(t){return"$event.".concat(t,"Key")})).join("||"))}else b.push(e)};for(var r in t.modifiers)c(r);b.length&&(p+=function(t){return"if(!$event.type.indexOf('key')&&"+"".concat(t.map(mc).join("&&"),")return null;")}(b)),M&&(p+=M);var z=e?"return ".concat(t.value,".apply(null, arguments)"):n?"return (".concat(t.value,").apply(null, arguments)"):o?"return ".concat(t.value):t.value;return"function($event){".concat(p).concat(z,"}")}return e||n?t.value:"function($event){".concat(o?"return ".concat(t.value):t.value,"}")}function mc(t){var e=parseInt(t,10);if(e)return"$event.keyCode!==".concat(e);var n=fc[t],o=qc[t];return"_k($event.keyCode,"+"".concat(JSON.stringify(t),",")+"".concat(JSON.stringify(n),",")+"$event.key,"+"".concat(JSON.stringify(o))+")"}var gc={on:function(t,e){t.wrapListeners=function(t){return"_g(".concat(t,",").concat(e.value,")")}},bind:function(t,e){t.wrapData=function(n){return"_b(".concat(n,",'").concat(t.tag,"',").concat(e.value,",").concat(e.modifiers&&e.modifiers.prop?"true":"false").concat(e.modifiers&&e.modifiers.sync?",true":"",")")}},cloak:w},Lc=function(t){this.options=t,this.warn=t.warn||ip,this.transforms=Op(t.modules,"transformCode"),this.dataGenFns=Op(t.modules,"genData"),this.directives=B(B({},gc),t.directives);var e=t.isReservedTag||S;this.maybeComponent=function(t){return!!t.component||!e(t.tag)},this.onceId=0,this.staticRenderFns=[],this.pre=!1};function yc(t,e){var n=new Lc(e),o=t?"script"===t.tag?"null":_c(t,n):'_c("div")';return{render:"with(this){return ".concat(o,"}"),staticRenderFns:n.staticRenderFns}}function _c(t,e){if(t.parent&&(t.pre=t.pre||t.parent.pre),t.staticRoot&&!t.staticProcessed)return Nc(t,e);if(t.once&&!t.onceProcessed)return Ec(t,e);if(t.for&&!t.forProcessed)return Cc(t,e);if(t.if&&!t.ifProcessed)return Tc(t,e);if("template"!==t.tag||t.slotTarget||e.pre){if("slot"===t.tag)return function(t,e){var n=t.slotName||'"default"',o=xc(t,e),p="_t(".concat(n).concat(o?",function(){return ".concat(o,"}"):""),M=t.attrs||t.dynamicAttrs?Dc((t.attrs||[]).concat(t.dynamicAttrs||[]).map((function(t){return{name:L(t.name),value:t.value,dynamic:t.dynamic}}))):null,b=t.attrsMap["v-bind"];!M&&!b||o||(p+=",null");M&&(p+=",".concat(M));b&&(p+="".concat(M?"":",null",",").concat(b));return p+")"}(t,e);var n=void 0;if(t.component)n=function(t,e,n){var o=e.inlineTemplate?null:xc(e,n,!0);return"_c(".concat(t,",").concat(wc(e,n)).concat(o?",".concat(o):"",")")}(t.component,t,e);else{var o=void 0,p=e.maybeComponent(t);(!t.plain||t.pre&&p)&&(o=wc(t,e));var M=void 0,b=e.options.bindings;p&&b&&!1!==b.__isScriptSetup&&(M=function(t,e){var n=L(e),o=y(n),p=function(p){return t[e]===p?e:t[n]===p?n:t[o]===p?o:void 0},M=p("setup-const")||p("setup-reactive-const");if(M)return M;var b=p("setup-let")||p("setup-ref")||p("setup-maybe-ref");if(b)return b}(b,t.tag)),M||(M="'".concat(t.tag,"'"));var c=t.inlineTemplate?null:xc(t,e,!0);n="_c(".concat(M).concat(o?",".concat(o):"").concat(c?",".concat(c):"",")")}for(var r=0;r>>0}(b)):"",")")}(t,t.scopedSlots,e),",")),t.model&&(n+="model:{value:".concat(t.model.value,",callback:").concat(t.model.callback,",expression:").concat(t.model.expression,"},")),t.inlineTemplate){var M=function(t,e){var n=t.children[0];0;if(n&&1===n.type){var o=yc(n,e.options);return"inlineTemplate:{render:function(){".concat(o.render,"},staticRenderFns:[").concat(o.staticRenderFns.map((function(t){return"function(){".concat(t,"}")})).join(","),"]}")}}(t,e);M&&(n+="".concat(M,","))}return n=n.replace(/,$/,"")+"}",t.dynamicAttrs&&(n="_b(".concat(n,',"').concat(t.tag,'",').concat(Dc(t.dynamicAttrs),")")),t.wrapData&&(n=t.wrapData(n)),t.wrapListeners&&(n=t.wrapListeners(n)),n}function Sc(t){return 1===t.type&&("slot"===t.tag||t.children.some(Sc))}function Xc(t,e){var n=t.attrsMap["slot-scope"];if(t.if&&!t.ifProcessed&&!n)return Tc(t,e,Xc,"null");if(t.for&&!t.forProcessed)return Cc(t,e,Xc);var o=t.slotScope===Yb?"":String(t.slotScope),p="function(".concat(o,"){")+"return ".concat("template"===t.tag?t.if&&n?"(".concat(t.if,")?").concat(xc(t,e)||"undefined",":undefined"):xc(t,e)||"undefined":_c(t,e),"}"),M=o?"":",proxy:true";return"{key:".concat(t.slotTarget||'"default"',",fn:").concat(p).concat(M,"}")}function xc(t,e,n,o,p){var M=t.children;if(M.length){var b=M[0];if(1===M.length&&b.for&&"template"!==b.tag&&"slot"!==b.tag){var c=n?e.maybeComponent(b)?",1":",0":"";return"".concat((o||_c)(b,e)).concat(c)}var r=n?function(t,e){for(var n=0,o=0;o':'
      ',Fc.innerHTML.indexOf(" ")>0}var Vc=!!K&&$c(!1),Kc=!!K&&$c(!0),Qc=m((function(t){var e=wo(t);return e&&e.innerHTML})),Jc=no.prototype.$mount;no.prototype.$mount=function(t,e){if((t=t&&wo(t))===document.body||t===document.documentElement)return this;var n=this.$options;if(!n.render){var o=n.template;if(o)if("string"==typeof o)"#"===o.charAt(0)&&(o=Qc(o));else{if(!o.nodeType)return this;o=o.innerHTML}else t&&(o=function(t){if(t.outerHTML)return t.outerHTML;var e=document.createElement("div");return e.appendChild(t.cloneNode(!0)),e.innerHTML}(t));if(o){0;var p=Yc(o,{outputSourceRange:!1,shouldDecodeNewlines:Vc,shouldDecodeNewlinesForHref:Kc,delimiters:n.delimiters,comments:n.comments},this),M=p.render,b=p.staticRenderFns;n.render=M,n.staticRenderFns=b}}return Jc.call(this,t,e)},no.compile=Yc;var Zc=n(2543),tr=n.n(Zc),er=n(4743),nr=n.n(er);const or={computed:{Telescope:function(t){function e(){return t.apply(this,arguments)}return e.toString=function(){return t.toString()},e}((function(){return Telescope}))},methods:{timeAgo:function(t){nr().updateLocale("en",{relativeTime:{future:"in %s",past:"%s ago",s:function(t){return t+"s ago"},ss:"%ds ago",m:"1m ago",mm:"%dm ago",h:"1h ago",hh:"%dh ago",d:"1d ago",dd:"%dd ago",M:"a month ago",MM:"%d months ago",y:"a year ago",yy:"%d years ago"}});var e=nr()().diff(t,"seconds"),n=nr()("2018-01-01").startOf("day").seconds(e);return e>300?nr()(t).fromNow(!0):e<60?n.format("s")+"s ago":n.format("m:ss")+"m ago"},localTime:function(t){return nr()(t).local().format("MMMM Do YYYY, h:mm:ss A")},truncate:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:70;return tr().truncate(t,{length:e,separator:/,? +/})},debouncer:tr().debounce((function(t){return t()}),500),alertError:function(t){this.$root.alert.type="error",this.$root.alert.autoClose=!1,this.$root.alert.message=t},alertSuccess:function(t,e){this.$root.alert.type="success",this.$root.alert.autoClose=e,this.$root.alert.message=t},alertConfirm:function(t,e,n){this.$root.alert.type="confirmation",this.$root.alert.autoClose=!1,this.$root.alert.message=t,this.$root.alert.confirmationProceed=e,this.$root.alert.confirmationCancel=n}}};var pr=n(4335);const Mr=[{path:"/",redirect:"/requests"},{path:"/mail/:id",name:"mail-preview",component:n(583).A},{path:"/mail",name:"mail",component:n(1574).A},{path:"/exceptions/:id",name:"exception-preview",component:n(3781).A},{path:"/exceptions",name:"exceptions",component:n(2977).A},{path:"/dumps",name:"dumps",component:n(93).A},{path:"/logs/:id",name:"log-preview",component:n(5356).A},{path:"/logs",name:"logs",component:n(8170).A},{path:"/notifications/:id",name:"notification-preview",component:n(5841).A},{path:"/notifications",name:"notifications",component:n(4969).A},{path:"/jobs/:id",name:"job-preview",component:n(8813).A},{path:"/jobs",name:"jobs",component:n(1202).A},{path:"/batches/:id",name:"batch-preview",component:n(9622).A},{path:"/batches",name:"batches",component:n(8888).A},{path:"/events/:id",name:"event-preview",component:n(1119).A},{path:"/events",name:"events",component:n(7380).A},{path:"/cache/:id",name:"cache-preview",component:n(7362).A},{path:"/cache",name:"cache",component:n(8613).A},{path:"/queries/:id",name:"query-preview",component:n(1891).A},{path:"/queries",name:"queries",component:n(5873).A},{path:"/models/:id",name:"model-preview",component:n(5333).A},{path:"/models",name:"models",component:n(9440).A},{path:"/requests/:id",name:"request-preview",component:n(8827).A},{path:"/requests",name:"requests",component:n(2806).A},{path:"/commands/:id",name:"command-preview",component:n(4145).A},{path:"/commands",name:"commands",component:n(346).A},{path:"/schedule/:id",name:"schedule-preview",component:n(9655).A},{path:"/schedule",name:"schedule",component:n(3524).A},{path:"/redis/:id",name:"redis-preview",component:n(6393).A},{path:"/redis",name:"redis",component:n(8872).A},{path:"/monitored-tags",name:"monitored-tags",component:n(5441).A},{path:"/gates/:id",name:"gate-preview",component:n(1905).A},{path:"/gates",name:"gates",component:n(2707).A},{path:"/views/:id",name:"view-preview",component:n(6703).A},{path:"/views",name:"views",component:n(3308).A},{path:"/client-requests/:id",name:"client-request-preview",component:n(6204).A},{path:"/client-requests",name:"client-requests",component:n(5899).A}];function br(t,e){for(var n in e)t[n]=e[n];return t}var cr=/[!'()*]/g,rr=function(t){return"%"+t.charCodeAt(0).toString(16)},zr=/%2C/g,ar=function(t){return encodeURIComponent(t).replace(cr,rr).replace(zr,",")};function ir(t){try{return decodeURIComponent(t)}catch(t){0}return t}var Or=function(t){return null==t||"object"==typeof t?t:String(t)};function sr(t){var e={};return(t=t.trim().replace(/^(\?|#|&)/,""))?(t.split("&").forEach((function(t){var n=t.replace(/\+/g," ").split("="),o=ir(n.shift()),p=n.length>0?ir(n.join("=")):null;void 0===e[o]?e[o]=p:Array.isArray(e[o])?e[o].push(p):e[o]=[e[o],p]})),e):e}function Ar(t){var e=t?Object.keys(t).map((function(e){var n=t[e];if(void 0===n)return"";if(null===n)return ar(e);if(Array.isArray(n)){var o=[];return n.forEach((function(t){void 0!==t&&(null===t?o.push(ar(e)):o.push(ar(e)+"="+ar(t)))})),o.join("&")}return ar(e)+"="+ar(n)})).filter((function(t){return t.length>0})).join("&"):null;return e?"?"+e:""}var ur=/\/?$/;function lr(t,e,n,o){var p=o&&o.options.stringifyQuery,M=e.query||{};try{M=dr(M)}catch(t){}var b={name:e.name||t&&t.name,meta:t&&t.meta||{},path:e.path||"/",hash:e.hash||"",query:M,params:e.params||{},fullPath:hr(e,p),matched:t?qr(t):[]};return n&&(b.redirectedFrom=hr(n,p)),Object.freeze(b)}function dr(t){if(Array.isArray(t))return t.map(dr);if(t&&"object"==typeof t){var e={};for(var n in t)e[n]=dr(t[n]);return e}return t}var fr=lr(null,{path:"/"});function qr(t){for(var e=[];t;)e.unshift(t),t=t.parent;return e}function hr(t,e){var n=t.path,o=t.query;void 0===o&&(o={});var p=t.hash;return void 0===p&&(p=""),(n||"/")+(e||Ar)(o)+p}function Wr(t,e,n){return e===fr?t===e:!!e&&(t.path&&e.path?t.path.replace(ur,"")===e.path.replace(ur,"")&&(n||t.hash===e.hash&&vr(t.query,e.query)):!(!t.name||!e.name)&&(t.name===e.name&&(n||t.hash===e.hash&&vr(t.query,e.query)&&vr(t.params,e.params))))}function vr(t,e){if(void 0===t&&(t={}),void 0===e&&(e={}),!t||!e)return t===e;var n=Object.keys(t).sort(),o=Object.keys(e).sort();return n.length===o.length&&n.every((function(n,p){var M=t[n];if(o[p]!==n)return!1;var b=e[n];return null==M||null==b?M===b:"object"==typeof M&&"object"==typeof b?vr(M,b):String(M)===String(b)}))}function Rr(t){for(var e=0;e=0&&(e=t.slice(o),t=t.slice(0,o));var p=t.indexOf("?");return p>=0&&(n=t.slice(p+1),t=t.slice(0,p)),{path:t,query:n,hash:e}}(p.path||""),z=e&&e.path||"/",a=r.path?Lr(r.path,z,n||p.append):z,i=function(t,e,n){void 0===e&&(e={});var o,p=n||sr;try{o=p(t||"")}catch(t){o={}}for(var M in e){var b=e[M];o[M]=Array.isArray(b)?b.map(Or):Or(b)}return o}(r.query,p.query,o&&o.options.parseQuery),O=p.hash||r.hash;return O&&"#"!==O.charAt(0)&&(O="#"+O),{_normalized:!0,path:a,query:i,hash:O}}var $r,Vr=function(){},Kr={name:"RouterLink",props:{to:{type:[String,Object],required:!0},tag:{type:String,default:"a"},custom:Boolean,exact:Boolean,exactPath:Boolean,append:Boolean,replace:Boolean,activeClass:String,exactActiveClass:String,ariaCurrentValue:{type:String,default:"page"},event:{type:[String,Array],default:"click"}},render:function(t){var e=this,n=this.$router,o=this.$route,p=n.resolve(this.to,o,this.append),M=p.location,b=p.route,c=p.href,r={},z=n.options.linkActiveClass,a=n.options.linkExactActiveClass,i=null==z?"router-link-active":z,O=null==a?"router-link-exact-active":a,s=null==this.activeClass?i:this.activeClass,A=null==this.exactActiveClass?O:this.exactActiveClass,u=b.redirectedFrom?lr(null,Yr(b.redirectedFrom),null,n):b;r[A]=Wr(o,u,this.exactPath),r[s]=this.exact||this.exactPath?r[A]:function(t,e){return 0===t.path.replace(ur,"/").indexOf(e.path.replace(ur,"/"))&&(!e.hash||t.hash===e.hash)&&function(t,e){for(var n in e)if(!(n in t))return!1;return!0}(t.query,e.query)}(o,u);var l=r[A]?this.ariaCurrentValue:null,d=function(t){Qr(t)&&(e.replace?n.replace(M,Vr):n.push(M,Vr))},f={click:Qr};Array.isArray(this.event)?this.event.forEach((function(t){f[t]=d})):f[this.event]=d;var q={class:r},h=!this.$scopedSlots.$hasNormal&&this.$scopedSlots.default&&this.$scopedSlots.default({href:c,route:b,navigate:d,isActive:r[s],isExactActive:r[A]});if(h){if(1===h.length)return h[0];if(h.length>1||!h.length)return 0===h.length?t():t("span",{},h)}if("a"===this.tag)q.on=f,q.attrs={href:c,"aria-current":l};else{var W=Jr(this.$slots.default);if(W){W.isStatic=!1;var v=W.data=br({},W.data);for(var R in v.on=v.on||{},v.on){var m=v.on[R];R in f&&(v.on[R]=Array.isArray(m)?m:[m])}for(var g in f)g in v.on?v.on[g].push(f[g]):v.on[g]=d;var L=W.data.attrs=br({},W.data.attrs);L.href=c,L["aria-current"]=l}else q.on=f}return t(this.tag,q,this.$slots.default)}};function Qr(t){if(!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey||t.defaultPrevented||void 0!==t.button&&0!==t.button)){if(t.currentTarget&&t.currentTarget.getAttribute){var e=t.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(e))return}return t.preventDefault&&t.preventDefault(),!0}}function Jr(t){if(t)for(var e,n=0;n-1&&(c.params[O]=n.params[O]);return c.path=Gr(a.path,c.params),r(a,c,b)}if(c.path){c.params={};for(var s=0;s-1}function Ez(t,e){return Nz(t)&&t._isRouter&&(null==e||t.type===e)}function Tz(t,e,n){var o=function(p){p>=t.length?n():t[p]?e(t[p],(function(){o(p+1)})):o(p+1)};o(0)}function Bz(t){return function(e,n,o){var p=!1,M=0,b=null;Cz(t,(function(t,e,n,c){if("function"==typeof t&&void 0===t.cid){p=!0,M++;var r,z=Xz((function(e){var p;((p=e).__esModule||Sz&&"Module"===p[Symbol.toStringTag])&&(e=e.default),t.resolved="function"==typeof e?e:$r.extend(e),n.components[c]=e,--M<=0&&o()})),a=Xz((function(t){var e="Failed to resolve async component "+c+": "+t;b||(b=Nz(t)?t:new Error(e),o(b))}));try{r=t(z,a)}catch(t){a(t)}if(r)if("function"==typeof r.then)r.then(z,a);else{var i=r.component;i&&"function"==typeof i.then&&i.then(z,a)}}})),p||o()}}function Cz(t,e){return wz(t.map((function(t){return Object.keys(t.components).map((function(n){return e(t.components[n],t.instances[n],t,n)}))})))}function wz(t){return Array.prototype.concat.apply([],t)}var Sz="function"==typeof Symbol&&"symbol"==typeof Symbol.toStringTag;function Xz(t){var e=!1;return function(){for(var n=[],o=arguments.length;o--;)n[o]=arguments[o];if(!e)return e=!0,t.apply(this,n)}}var xz=function(t,e){this.router=t,this.base=function(t){if(!t)if(Zr){var e=document.querySelector("base");t=(t=e&&e.getAttribute("href")||"/").replace(/^https?:\/\/[^\/]+/,"")}else t="/";"/"!==t.charAt(0)&&(t="/"+t);return t.replace(/\/$/,"")}(e),this.current=fr,this.pending=null,this.ready=!1,this.readyCbs=[],this.readyErrorCbs=[],this.errorCbs=[],this.listeners=[]};function kz(t,e,n,o){var p=Cz(t,(function(t,o,p,M){var b=function(t,e){"function"!=typeof t&&(t=$r.extend(t));return t.options[e]}(t,e);if(b)return Array.isArray(b)?b.map((function(t){return n(t,o,p,M)})):n(b,o,p,M)}));return wz(o?p.reverse():p)}function Iz(t,e){if(e)return function(){return t.apply(e,arguments)}}xz.prototype.listen=function(t){this.cb=t},xz.prototype.onReady=function(t,e){this.ready?t():(this.readyCbs.push(t),e&&this.readyErrorCbs.push(e))},xz.prototype.onError=function(t){this.errorCbs.push(t)},xz.prototype.transitionTo=function(t,e,n){var o,p=this;try{o=this.router.match(t,this.current)}catch(t){throw this.errorCbs.forEach((function(e){e(t)})),t}var M=this.current;this.confirmTransition(o,(function(){p.updateRoute(o),e&&e(o),p.ensureURL(),p.router.afterHooks.forEach((function(t){t&&t(o,M)})),p.ready||(p.ready=!0,p.readyCbs.forEach((function(t){t(o)})))}),(function(t){n&&n(t),t&&!p.ready&&(Ez(t,mz.redirected)&&M===fr||(p.ready=!0,p.readyErrorCbs.forEach((function(e){e(t)}))))}))},xz.prototype.confirmTransition=function(t,e,n){var o=this,p=this.current;this.pending=t;var M,b,c=function(t){!Ez(t)&&Nz(t)&&o.errorCbs.length&&o.errorCbs.forEach((function(e){e(t)})),n&&n(t)},r=t.matched.length-1,z=p.matched.length-1;if(Wr(t,p)&&r===z&&t.matched[r]===p.matched[z])return this.ensureURL(),t.hash&&Oz(this.router,p,t,!1),c(((b=yz(M=p,t,mz.duplicated,'Avoided redundant navigation to current location: "'+M.fullPath+'".')).name="NavigationDuplicated",b));var a=function(t,e){var n,o=Math.max(t.length,e.length);for(n=0;n0)){var e=this.router,n=e.options.scrollBehavior,o=Wz&&n;o&&this.listeners.push(iz());var p=function(){var n=t.current,p=Pz(t.base);t.current===fr&&p===t._startLocation||t.transitionTo(p,(function(t){o&&Oz(e,t,n,!0)}))};window.addEventListener("popstate",p),this.listeners.push((function(){window.removeEventListener("popstate",p)}))}},e.prototype.go=function(t){window.history.go(t)},e.prototype.push=function(t,e,n){var o=this,p=this.current;this.transitionTo(t,(function(t){vz(yr(o.base+t.fullPath)),Oz(o.router,t,p,!1),e&&e(t)}),n)},e.prototype.replace=function(t,e,n){var o=this,p=this.current;this.transitionTo(t,(function(t){Rz(yr(o.base+t.fullPath)),Oz(o.router,t,p,!1),e&&e(t)}),n)},e.prototype.ensureURL=function(t){if(Pz(this.base)!==this.current.fullPath){var e=yr(this.base+this.current.fullPath);t?vz(e):Rz(e)}},e.prototype.getCurrentLocation=function(){return Pz(this.base)},e}(xz);function Pz(t){var e=window.location.pathname,n=e.toLowerCase(),o=t.toLowerCase();return!t||n!==o&&0!==n.indexOf(yr(o+"/"))||(e=e.slice(t.length)),(e||"/")+window.location.search+window.location.hash}var Uz=function(t){function e(e,n,o){t.call(this,e,n),o&&function(t){var e=Pz(t);if(!/^\/#/.test(e))return window.location.replace(yr(t+"/#"+e)),!0}(this.base)||jz()}return t&&(e.__proto__=t),e.prototype=Object.create(t&&t.prototype),e.prototype.constructor=e,e.prototype.setupListeners=function(){var t=this;if(!(this.listeners.length>0)){var e=this.router.options.scrollBehavior,n=Wz&&e;n&&this.listeners.push(iz());var o=function(){var e=t.current;jz()&&t.transitionTo(Hz(),(function(o){n&&Oz(t.router,o,e,!0),Wz||Yz(o.fullPath)}))},p=Wz?"popstate":"hashchange";window.addEventListener(p,o),this.listeners.push((function(){window.removeEventListener(p,o)}))}},e.prototype.push=function(t,e,n){var o=this,p=this.current;this.transitionTo(t,(function(t){Gz(t.fullPath),Oz(o.router,t,p,!1),e&&e(t)}),n)},e.prototype.replace=function(t,e,n){var o=this,p=this.current;this.transitionTo(t,(function(t){Yz(t.fullPath),Oz(o.router,t,p,!1),e&&e(t)}),n)},e.prototype.go=function(t){window.history.go(t)},e.prototype.ensureURL=function(t){var e=this.current.fullPath;Hz()!==e&&(t?Gz(e):Yz(e))},e.prototype.getCurrentLocation=function(){return Hz()},e}(xz);function jz(){var t=Hz();return"/"===t.charAt(0)||(Yz("/"+t),!1)}function Hz(){var t=window.location.href,e=t.indexOf("#");return e<0?"":t=t.slice(e+1)}function Fz(t){var e=window.location.href,n=e.indexOf("#");return(n>=0?e.slice(0,n):e)+"#"+t}function Gz(t){Wz?vz(Fz(t)):window.location.hash=t}function Yz(t){Wz?Rz(Fz(t)):window.location.replace(Fz(t))}var $z=function(t){function e(e,n){t.call(this,e,n),this.stack=[],this.index=-1}return t&&(e.__proto__=t),e.prototype=Object.create(t&&t.prototype),e.prototype.constructor=e,e.prototype.push=function(t,e,n){var o=this;this.transitionTo(t,(function(t){o.stack=o.stack.slice(0,o.index+1).concat(t),o.index++,e&&e(t)}),n)},e.prototype.replace=function(t,e,n){var o=this;this.transitionTo(t,(function(t){o.stack=o.stack.slice(0,o.index).concat(t),e&&e(t)}),n)},e.prototype.go=function(t){var e=this,n=this.index+t;if(!(n<0||n>=this.stack.length)){var o=this.stack[n];this.confirmTransition(o,(function(){var t=e.current;e.index=n,e.updateRoute(o),e.router.afterHooks.forEach((function(e){e&&e(o,t)}))}),(function(t){Ez(t,mz.duplicated)&&(e.index=n)}))}},e.prototype.getCurrentLocation=function(){var t=this.stack[this.stack.length-1];return t?t.fullPath:"/"},e.prototype.ensureURL=function(){},e}(xz),Vz=function(t){void 0===t&&(t={}),this.app=null,this.apps=[],this.options=t,this.beforeHooks=[],this.resolveHooks=[],this.afterHooks=[],this.matcher=oz(t.routes||[],this);var e=t.mode||"hash";switch(this.fallback="history"===e&&!Wz&&!1!==t.fallback,this.fallback&&(e="hash"),Zr||(e="abstract"),this.mode=e,e){case"history":this.history=new Dz(this,t.base);break;case"hash":this.history=new Uz(this,t.base,this.fallback);break;case"abstract":this.history=new $z(this,t.base)}},Kz={currentRoute:{configurable:!0}};Vz.prototype.match=function(t,e,n){return this.matcher.match(t,e,n)},Kz.currentRoute.get=function(){return this.history&&this.history.current},Vz.prototype.init=function(t){var e=this;if(this.apps.push(t),t.$once("hook:destroyed",(function(){var n=e.apps.indexOf(t);n>-1&&e.apps.splice(n,1),e.app===t&&(e.app=e.apps[0]||null),e.app||e.history.teardown()})),!this.app){this.app=t;var n=this.history;if(n instanceof Dz||n instanceof Uz){var o=function(t){n.setupListeners(),function(t){var o=n.current,p=e.options.scrollBehavior;Wz&&p&&"fullPath"in t&&Oz(e,t,o,!1)}(t)};n.transitionTo(n.getCurrentLocation(),o,o)}n.listen((function(t){e.apps.forEach((function(e){e._route=t}))}))}},Vz.prototype.beforeEach=function(t){return Jz(this.beforeHooks,t)},Vz.prototype.beforeResolve=function(t){return Jz(this.resolveHooks,t)},Vz.prototype.afterEach=function(t){return Jz(this.afterHooks,t)},Vz.prototype.onReady=function(t,e){this.history.onReady(t,e)},Vz.prototype.onError=function(t){this.history.onError(t)},Vz.prototype.push=function(t,e,n){var o=this;if(!e&&!n&&"undefined"!=typeof Promise)return new Promise((function(e,n){o.history.push(t,e,n)}));this.history.push(t,e,n)},Vz.prototype.replace=function(t,e,n){var o=this;if(!e&&!n&&"undefined"!=typeof Promise)return new Promise((function(e,n){o.history.replace(t,e,n)}));this.history.replace(t,e,n)},Vz.prototype.go=function(t){this.history.go(t)},Vz.prototype.back=function(){this.go(-1)},Vz.prototype.forward=function(){this.go(1)},Vz.prototype.getMatchedComponents=function(t){var e=t?t.matched?t:this.resolve(t).route:this.currentRoute;return e?[].concat.apply([],e.matched.map((function(t){return Object.keys(t.components).map((function(e){return t.components[e]}))}))):[]},Vz.prototype.resolve=function(t,e,n){var o=Yr(t,e=e||this.history.current,n,this),p=this.match(o,e),M=p.redirectedFrom||p.fullPath,b=function(t,e,n){var o="hash"===n?"#"+e:e;return t?yr(t+"/"+o):o}(this.history.base,M,this.mode);return{location:o,route:p,href:b,normalizedTo:o,resolved:p}},Vz.prototype.getRoutes=function(){return this.matcher.getRoutes()},Vz.prototype.addRoute=function(t,e){this.matcher.addRoute(t,e),this.history.current!==fr&&this.history.transitionTo(this.history.getCurrentLocation())},Vz.prototype.addRoutes=function(t){this.matcher.addRoutes(t),this.history.current!==fr&&this.history.transitionTo(this.history.getCurrentLocation())},Object.defineProperties(Vz.prototype,Kz);var Qz=Vz;function Jz(t,e){return t.push(e),function(){var n=t.indexOf(e);n>-1&&t.splice(n,1)}}Vz.install=function t(e){if(!t.installed||$r!==e){t.installed=!0,$r=e;var n=function(t){return void 0!==t},o=function(t,e){var o=t.$options._parentVnode;n(o)&&n(o=o.data)&&n(o=o.registerRouteInstance)&&o(t,e)};e.mixin({beforeCreate:function(){n(this.$options.router)?(this._routerRoot=this,this._router=this.$options.router,this._router.init(this),e.util.defineReactive(this,"_route",this._router.history.current)):this._routerRoot=this.$parent&&this.$parent._routerRoot||this,o(this,this)},destroyed:function(){o(this)}}),Object.defineProperty(e.prototype,"$router",{get:function(){return this._routerRoot._router}}),Object.defineProperty(e.prototype,"$route",{get:function(){return this._routerRoot._route}}),e.component("RouterView",mr),e.component("RouterLink",Kr);var p=e.config.optionMergeStrategies;p.beforeRouteEnter=p.beforeRouteLeave=p.beforeRouteUpdate=p.created}},Vz.version="3.6.5",Vz.isNavigationFailure=Ez,Vz.NavigationFailureType=mz,Vz.START_LOCATION=fr,Zr&&window.Vue&&window.Vue.use(Vz);var Zz=n(7551),ta=n.n(Zz),ea=n(5072),na=n.n(ea),oa=n(2930),pa={insert:"head",singleton:!1};na()(oa.A,pa);oa.A.locals;n(2754);var Ma=document.head.querySelector('meta[name="csrf-token"]');Ma&&(pr.A.defaults.headers.common["X-CSRF-TOKEN"]=Ma.content),no.use(Qz),window.Popper=n(8851).default,nr().tz.setDefault(Telescope.timezone),window.Telescope.basePath="/"+window.Telescope.path;var ba=window.Telescope.basePath+"/";""!==window.Telescope.path&&"/"!==window.Telescope.path||(ba="/",window.Telescope.basePath="");var ca=new Qz({routes:Mr,mode:"history",base:ba});no.component("vue-json-pretty",ta()),no.component("related-entries",n(8117).A),no.component("index-screen",n(4980).A),no.component("preview-screen",n(9416).A),no.component("alert",n(4445).A),no.component("copy-clipboard",n(1858).A),no.mixin(or),new no({el:"#telescope",router:ca,data:function(){return{alert:{type:null,autoClose:0,message:"",confirmationProceed:null,confirmationCancel:null},autoLoadsNewEntries:"1"===localStorage.autoLoadsNewEntries,recording:Telescope.recording}},created:function(){window.addEventListener("keydown",this.keydownListener)},destroyed:function(){window.removeEventListener("keydown",this.keydownListener)},methods:{autoLoadNewEntries:function(){this.autoLoadsNewEntries?(this.autoLoadsNewEntries=!1,localStorage.autoLoadsNewEntries=0):(this.autoLoadsNewEntries=!0,localStorage.autoLoadsNewEntries=1)},toggleRecording:function(){pr.A.post(Telescope.basePath+"/telescope-api/toggle-recording"),window.Telescope.recording=!Telescope.recording,this.recording=!this.recording},clearEntries:function(){(!(arguments.length>0&&void 0!==arguments[0])||arguments[0])&&!confirm("Are you sure you want to delete all Telescope data?")||pr.A.delete(Telescope.basePath+"/telescope-api/entries").then((function(t){return location.reload()}))},keydownListener:function(t){t.metaKey&&"k"===t.key&&this.clearEntries(!1)}}})},8217:(t,e,n)=>{"use strict";n.d(e,{A:()=>o});const o={methods:{cacheActionTypeClass:function(t){return"hit"===t?"success":"set"===t?"info":"forget"===t?"warning":"missed"===t?"danger":void 0},composerTypeClass:function(t){return"composer"===t?"info":"creator"===t?"success":void 0},gateResultClass:function(t){return"allowed"===t?"success":"denied"===t?"danger":void 0},jobStatusClass:function(t){return"pending"===t?"secondary":"processed"===t?"success":"failed"===t?"danger":void 0},logLevelClass:function(t){return"debug"===t?"success":"info"===t?"info":"notice"===t?"secondary":"warning"===t?"warning":"error"===t||"critical"===t||"alert"===t||"emergency"===t?"danger":void 0},modelActionClass:function(t){return"created"==t?"success":"updated"==t?"info":"retrieved"==t?"secondary":"deleted"==t||"forceDeleted"==t?"danger":void 0},requestStatusClass:function(t){return t?t<300?"success":t<400?"info":t<500?"warning":t>=500?"danger":void 0:"danger"},requestMethodClass:function(t){return"GET"==t||"OPTIONS"==t?"secondary":"POST"==t||"PATCH"==t||"PUT"==t?"info":"DELETE"==t?"danger":void 0}}}},7526:(t,e)=>{"use strict";e.byteLength=function(t){var e=c(t),n=e[0],o=e[1];return 3*(n+o)/4-o},e.toByteArray=function(t){var e,n,M=c(t),b=M[0],r=M[1],z=new p(function(t,e,n){return 3*(e+n)/4-n}(0,b,r)),a=0,i=r>0?b-4:b;for(n=0;n>16&255,z[a++]=e>>8&255,z[a++]=255&e;2===r&&(e=o[t.charCodeAt(n)]<<2|o[t.charCodeAt(n+1)]>>4,z[a++]=255&e);1===r&&(e=o[t.charCodeAt(n)]<<10|o[t.charCodeAt(n+1)]<<4|o[t.charCodeAt(n+2)]>>2,z[a++]=e>>8&255,z[a++]=255&e);return z},e.fromByteArray=function(t){for(var e,o=t.length,p=o%3,M=[],b=16383,c=0,z=o-p;cz?z:c+b));1===p?(e=t[o-1],M.push(n[e>>2]+n[e<<4&63]+"==")):2===p&&(e=(t[o-2]<<8)+t[o-1],M.push(n[e>>10]+n[e>>4&63]+n[e<<2&63]+"="));return M.join("")};for(var n=[],o=[],p="undefined"!=typeof Uint8Array?Uint8Array:Array,M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",b=0;b<64;++b)n[b]=M[b],o[M.charCodeAt(b)]=b;function c(t){var e=t.length;if(e%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var n=t.indexOf("=");return-1===n&&(n=e),[n,n===e?0:4-n%4]}function r(t,e,o){for(var p,M,b=[],c=e;c>18&63]+n[M>>12&63]+n[M>>6&63]+n[63&M]);return b.join("")}o["-".charCodeAt(0)]=62,o["_".charCodeAt(0)]=63},2754:function(t,e,n){!function(t,e,n){"use strict";function o(t){return t&&"object"==typeof t&&"default"in t?t:{default:t}}var p=o(e),M=o(n);function b(t,e){for(var n=0;n=b)throw new Error("Bootstrap's JavaScript requires at least jQuery v1.9.1 but less than v4.0.0")}};f.jQueryDetection(),d();var q="alert",h="4.6.2",W="bs.alert",v="."+W,R=".data-api",m=p.default.fn[q],g="alert",L="fade",y="show",_="close"+v,N="closed"+v,E="click"+v+R,T='[data-dismiss="alert"]',B=function(){function t(t){this._element=t}var e=t.prototype;return e.close=function(t){var e=this._element;t&&(e=this._getRootElement(t)),this._triggerCloseEvent(e).isDefaultPrevented()||this._removeElement(e)},e.dispose=function(){p.default.removeData(this._element,W),this._element=null},e._getRootElement=function(t){var e=f.getSelectorFromElement(t),n=!1;return e&&(n=document.querySelector(e)),n||(n=p.default(t).closest("."+g)[0]),n},e._triggerCloseEvent=function(t){var e=p.default.Event(_);return p.default(t).trigger(e),e},e._removeElement=function(t){var e=this;if(p.default(t).removeClass(y),p.default(t).hasClass(L)){var n=f.getTransitionDurationFromElement(t);p.default(t).one(f.TRANSITION_END,(function(n){return e._destroyElement(t,n)})).emulateTransitionEnd(n)}else this._destroyElement(t)},e._destroyElement=function(t){p.default(t).detach().trigger(N).remove()},t._jQueryInterface=function(e){return this.each((function(){var n=p.default(this),o=n.data(W);o||(o=new t(this),n.data(W,o)),"close"===e&&o[e](this)}))},t._handleDismiss=function(t){return function(e){e&&e.preventDefault(),t.close(this)}},c(t,null,[{key:"VERSION",get:function(){return h}}]),t}();p.default(document).on(E,T,B._handleDismiss(new B)),p.default.fn[q]=B._jQueryInterface,p.default.fn[q].Constructor=B,p.default.fn[q].noConflict=function(){return p.default.fn[q]=m,B._jQueryInterface};var C="button",w="4.6.2",S="bs.button",X="."+S,x=".data-api",k=p.default.fn[C],I="active",D="btn",P="focus",U="click"+X+x,j="focus"+X+x+" blur"+X+x,H="load"+X+x,F='[data-toggle^="button"]',G='[data-toggle="buttons"]',Y='[data-toggle="button"]',$='[data-toggle="buttons"] .btn',V='input:not([type="hidden"])',K=".active",Q=".btn",J=function(){function t(t){this._element=t,this.shouldAvoidTriggerChange=!1}var e=t.prototype;return e.toggle=function(){var t=!0,e=!0,n=p.default(this._element).closest(G)[0];if(n){var o=this._element.querySelector(V);if(o){if("radio"===o.type)if(o.checked&&this._element.classList.contains(I))t=!1;else{var M=n.querySelector(K);M&&p.default(M).removeClass(I)}t&&("checkbox"!==o.type&&"radio"!==o.type||(o.checked=!this._element.classList.contains(I)),this.shouldAvoidTriggerChange||p.default(o).trigger("change")),o.focus(),e=!1}}this._element.hasAttribute("disabled")||this._element.classList.contains("disabled")||(e&&this._element.setAttribute("aria-pressed",!this._element.classList.contains(I)),t&&p.default(this._element).toggleClass(I))},e.dispose=function(){p.default.removeData(this._element,S),this._element=null},t._jQueryInterface=function(e,n){return this.each((function(){var o=p.default(this),M=o.data(S);M||(M=new t(this),o.data(S,M)),M.shouldAvoidTriggerChange=n,"toggle"===e&&M[e]()}))},c(t,null,[{key:"VERSION",get:function(){return w}}]),t}();p.default(document).on(U,F,(function(t){var e=t.target,n=e;if(p.default(e).hasClass(D)||(e=p.default(e).closest(Q)[0]),!e||e.hasAttribute("disabled")||e.classList.contains("disabled"))t.preventDefault();else{var o=e.querySelector(V);if(o&&(o.hasAttribute("disabled")||o.classList.contains("disabled")))return void t.preventDefault();"INPUT"!==n.tagName&&"LABEL"===e.tagName||J._jQueryInterface.call(p.default(e),"toggle","INPUT"===n.tagName)}})).on(j,F,(function(t){var e=p.default(t.target).closest(Q)[0];p.default(e).toggleClass(P,/^focus(in)?$/.test(t.type))})),p.default(window).on(H,(function(){for(var t=[].slice.call(document.querySelectorAll($)),e=0,n=t.length;e0,this._pointerEvent=Boolean(window.PointerEvent||window.MSPointerEvent),this._addEventListeners()}var e=t.prototype;return e.next=function(){this._isSliding||this._slide(dt)},e.nextWhenVisible=function(){var t=p.default(this._element);!document.hidden&&t.is(":visible")&&"hidden"!==t.css("visibility")&&this.next()},e.prev=function(){this._isSliding||this._slide(ft)},e.pause=function(t){t||(this._isPaused=!0),this._element.querySelector(kt)&&(f.triggerTransitionEnd(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null},e.cycle=function(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))},e.to=function(t){var e=this;this._activeElement=this._element.querySelector(St);var n=this._getItemIndex(this._activeElement);if(!(t>this._items.length-1||t<0))if(this._isSliding)p.default(this._element).one(vt,(function(){return e.to(t)}));else{if(n===t)return this.pause(),void this.cycle();var o=t>n?dt:ft;this._slide(o,this._items[t])}},e.dispose=function(){p.default(this._element).off(nt),p.default.removeData(this._element,et),this._items=null,this._config=null,this._element=null,this._interval=null,this._isPaused=null,this._isSliding=null,this._activeElement=null,this._indicatorsElement=null},e._getConfig=function(t){return t=r({},Ut,t),f.typeCheckConfig(Z,t,jt),t},e._handleSwipe=function(){var t=Math.abs(this.touchDeltaX);if(!(t<=rt)){var e=t/this.touchDeltaX;this.touchDeltaX=0,e>0&&this.prev(),e<0&&this.next()}},e._addEventListeners=function(){var t=this;this._config.keyboard&&p.default(this._element).on(Rt,(function(e){return t._keydown(e)})),"hover"===this._config.pause&&p.default(this._element).on(mt,(function(e){return t.pause(e)})).on(gt,(function(e){return t.cycle(e)})),this._config.touch&&this._addTouchEventListeners()},e._addTouchEventListeners=function(){var t=this;if(this._touchSupported){var e=function(e){t._pointerEvent&&Ht[e.originalEvent.pointerType.toUpperCase()]?t.touchStartX=e.originalEvent.clientX:t._pointerEvent||(t.touchStartX=e.originalEvent.touches[0].clientX)},n=function(e){t.touchDeltaX=e.originalEvent.touches&&e.originalEvent.touches.length>1?0:e.originalEvent.touches[0].clientX-t.touchStartX},o=function(e){t._pointerEvent&&Ht[e.originalEvent.pointerType.toUpperCase()]&&(t.touchDeltaX=e.originalEvent.clientX-t.touchStartX),t._handleSwipe(),"hover"===t._config.pause&&(t.pause(),t.touchTimeout&&clearTimeout(t.touchTimeout),t.touchTimeout=setTimeout((function(e){return t.cycle(e)}),ct+t._config.interval))};p.default(this._element.querySelectorAll(xt)).on(Tt,(function(t){return t.preventDefault()})),this._pointerEvent?(p.default(this._element).on(Nt,(function(t){return e(t)})),p.default(this._element).on(Et,(function(t){return o(t)})),this._element.classList.add(lt)):(p.default(this._element).on(Lt,(function(t){return e(t)})),p.default(this._element).on(yt,(function(t){return n(t)})),p.default(this._element).on(_t,(function(t){return o(t)})))}},e._keydown=function(t){if(!/input|textarea/i.test(t.target.tagName))switch(t.which){case Mt:t.preventDefault(),this.prev();break;case bt:t.preventDefault(),this.next()}},e._getItemIndex=function(t){return this._items=t&&t.parentNode?[].slice.call(t.parentNode.querySelectorAll(Xt)):[],this._items.indexOf(t)},e._getItemByDirection=function(t,e){var n=t===dt,o=t===ft,p=this._getItemIndex(e),M=this._items.length-1;if((o&&0===p||n&&p===M)&&!this._config.wrap)return e;var b=(p+(t===ft?-1:1))%this._items.length;return-1===b?this._items[this._items.length-1]:this._items[b]},e._triggerSlideEvent=function(t,e){var n=this._getItemIndex(t),o=this._getItemIndex(this._element.querySelector(St)),M=p.default.Event(Wt,{relatedTarget:t,direction:e,from:o,to:n});return p.default(this._element).trigger(M),M},e._setActiveIndicatorElement=function(t){if(this._indicatorsElement){var e=[].slice.call(this._indicatorsElement.querySelectorAll(wt));p.default(e).removeClass(at);var n=this._indicatorsElement.children[this._getItemIndex(t)];n&&p.default(n).addClass(at)}},e._updateInterval=function(){var t=this._activeElement||this._element.querySelector(St);if(t){var e=parseInt(t.getAttribute("data-interval"),10);e?(this._config.defaultInterval=this._config.defaultInterval||this._config.interval,this._config.interval=e):this._config.interval=this._config.defaultInterval||this._config.interval}},e._slide=function(t,e){var n,o,M,b=this,c=this._element.querySelector(St),r=this._getItemIndex(c),z=e||c&&this._getItemByDirection(t,c),a=this._getItemIndex(z),i=Boolean(this._interval);if(t===dt?(n=st,o=At,M=qt):(n=Ot,o=ut,M=ht),z&&p.default(z).hasClass(at))this._isSliding=!1;else if(!this._triggerSlideEvent(z,M).isDefaultPrevented()&&c&&z){this._isSliding=!0,i&&this.pause(),this._setActiveIndicatorElement(z),this._activeElement=z;var O=p.default.Event(vt,{relatedTarget:z,direction:M,from:r,to:a});if(p.default(this._element).hasClass(it)){p.default(z).addClass(o),f.reflow(z),p.default(c).addClass(n),p.default(z).addClass(n);var s=f.getTransitionDurationFromElement(c);p.default(c).one(f.TRANSITION_END,(function(){p.default(z).removeClass(n+" "+o).addClass(at),p.default(c).removeClass(at+" "+o+" "+n),b._isSliding=!1,setTimeout((function(){return p.default(b._element).trigger(O)}),0)})).emulateTransitionEnd(s)}else p.default(c).removeClass(at),p.default(z).addClass(at),this._isSliding=!1,p.default(this._element).trigger(O);i&&this.cycle()}},t._jQueryInterface=function(e){return this.each((function(){var n=p.default(this).data(et),o=r({},Ut,p.default(this).data());"object"==typeof e&&(o=r({},o,e));var M="string"==typeof e?e:o.slide;if(n||(n=new t(this,o),p.default(this).data(et,n)),"number"==typeof e)n.to(e);else if("string"==typeof M){if(void 0===n[M])throw new TypeError('No method named "'+M+'"');n[M]()}else o.interval&&o.ride&&(n.pause(),n.cycle())}))},t._dataApiClickHandler=function(e){var n=f.getSelectorFromElement(this);if(n){var o=p.default(n)[0];if(o&&p.default(o).hasClass(zt)){var M=r({},p.default(o).data(),p.default(this).data()),b=this.getAttribute("data-slide-to");b&&(M.interval=!1),t._jQueryInterface.call(p.default(o),M),b&&p.default(o).data(et).to(b),e.preventDefault()}}},c(t,null,[{key:"VERSION",get:function(){return tt}},{key:"Default",get:function(){return Ut}}]),t}();p.default(document).on(Ct,Dt,Ft._dataApiClickHandler),p.default(window).on(Bt,(function(){for(var t=[].slice.call(document.querySelectorAll(Pt)),e=0,n=t.length;e0&&(this._selector=b,this._triggerArray.push(M))}this._parent=this._config.parent?this._getParent():null,this._config.parent||this._addAriaAndCollapsedClass(this._element,this._triggerArray),this._config.toggle&&this.toggle()}var e=t.prototype;return e.toggle=function(){p.default(this._element).hasClass(Jt)?this.hide():this.show()},e.show=function(){var e,n,o=this;if(!(this._isTransitioning||p.default(this._element).hasClass(Jt)||(this._parent&&0===(e=[].slice.call(this._parent.querySelectorAll(ze)).filter((function(t){return"string"==typeof o._config.parent?t.getAttribute("data-parent")===o._config.parent:t.classList.contains(Zt)}))).length&&(e=null),e&&(n=p.default(e).not(this._selector).data($t))&&n._isTransitioning))){var M=p.default.Event(pe);if(p.default(this._element).trigger(M),!M.isDefaultPrevented()){e&&(t._jQueryInterface.call(p.default(e).not(this._selector),"hide"),n||p.default(e).data($t,null));var b=this._getDimension();p.default(this._element).removeClass(Zt).addClass(te),this._element.style[b]=0,this._triggerArray.length&&p.default(this._triggerArray).removeClass(ee).attr("aria-expanded",!0),this.setTransitioning(!0);var c=function(){p.default(o._element).removeClass(te).addClass(Zt+" "+Jt),o._element.style[b]="",o.setTransitioning(!1),p.default(o._element).trigger(Me)},r="scroll"+(b[0].toUpperCase()+b.slice(1)),z=f.getTransitionDurationFromElement(this._element);p.default(this._element).one(f.TRANSITION_END,c).emulateTransitionEnd(z),this._element.style[b]=this._element[r]+"px"}}},e.hide=function(){var t=this;if(!this._isTransitioning&&p.default(this._element).hasClass(Jt)){var e=p.default.Event(be);if(p.default(this._element).trigger(e),!e.isDefaultPrevented()){var n=this._getDimension();this._element.style[n]=this._element.getBoundingClientRect()[n]+"px",f.reflow(this._element),p.default(this._element).addClass(te).removeClass(Zt+" "+Jt);var o=this._triggerArray.length;if(o>0)for(var M=0;M0},e._getOffset=function(){var t=this,e={};return"function"==typeof this._config.offset?e.fn=function(e){return e.offsets=r({},e.offsets,t._config.offset(e.offsets,t._element)),e}:e.offset=this._config.offset,e},e._getPopperConfig=function(){var t={placement:this._getPlacement(),modifiers:{offset:this._getOffset(),flip:{enabled:this._config.flip},preventOverflow:{boundariesElement:this._config.boundary}}};return"static"===this._config.display&&(t.modifiers.applyStyle={enabled:!1}),r({},t,this._config.popperConfig)},t._jQueryInterface=function(e){return this.each((function(){var n=p.default(this).data(le);if(n||(n=new t(this,"object"==typeof e?e:null),p.default(this).data(le,n)),"string"==typeof e){if(void 0===n[e])throw new TypeError('No method named "'+e+'"');n[e]()}}))},t._clearMenus=function(e){if(!e||e.which!==ge&&("keyup"!==e.type||e.which===ve))for(var n=[].slice.call(document.querySelectorAll(Ue)),o=0,M=n.length;o0&&b--,e.which===me&&bdocument.documentElement.clientHeight;n||(this._element.style.overflowY="hidden"),this._element.classList.add(ln);var o=f.getTransitionDurationFromElement(this._dialog);p.default(this._element).off(f.TRANSITION_END),p.default(this._element).one(f.TRANSITION_END,(function(){t._element.classList.remove(ln),n||p.default(t._element).one(f.TRANSITION_END,(function(){t._element.style.overflowY=""})).emulateTransitionEnd(t._element,o)})).emulateTransitionEnd(o),this._element.focus()}},e._showElement=function(t){var e=this,n=p.default(this._element).hasClass(An),o=this._dialog?this._dialog.querySelector(En):null;this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.appendChild(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),p.default(this._dialog).hasClass(zn)&&o?o.scrollTop=0:this._element.scrollTop=0,n&&f.reflow(this._element),p.default(this._element).addClass(un),this._config.focus&&this._enforceFocus();var M=p.default.Event(Wn,{relatedTarget:t}),b=function(){e._config.focus&&e._element.focus(),e._isTransitioning=!1,p.default(e._element).trigger(M)};if(n){var c=f.getTransitionDurationFromElement(this._dialog);p.default(this._dialog).one(f.TRANSITION_END,b).emulateTransitionEnd(c)}else b()},e._enforceFocus=function(){var t=this;p.default(document).off(vn).on(vn,(function(e){document!==e.target&&t._element!==e.target&&0===p.default(t._element).has(e.target).length&&t._element.focus()}))},e._setEscapeEvent=function(){var t=this;this._isShown?p.default(this._element).on(gn,(function(e){t._config.keyboard&&e.which===rn?(e.preventDefault(),t.hide()):t._config.keyboard||e.which!==rn||t._triggerBackdropTransition()})):this._isShown||p.default(this._element).off(gn)},e._setResizeEvent=function(){var t=this;this._isShown?p.default(window).on(Rn,(function(e){return t.handleUpdate(e)})):p.default(window).off(Rn)},e._hideModal=function(){var t=this;this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._showBackdrop((function(){p.default(document.body).removeClass(sn),t._resetAdjustments(),t._resetScrollbar(),p.default(t._element).trigger(qn)}))},e._removeBackdrop=function(){this._backdrop&&(p.default(this._backdrop).remove(),this._backdrop=null)},e._showBackdrop=function(t){var e=this,n=p.default(this._element).hasClass(An)?An:"";if(this._isShown&&this._config.backdrop){if(this._backdrop=document.createElement("div"),this._backdrop.className=On,n&&this._backdrop.classList.add(n),p.default(this._backdrop).appendTo(document.body),p.default(this._element).on(mn,(function(t){e._ignoreBackdropClick?e._ignoreBackdropClick=!1:t.target===t.currentTarget&&("static"===e._config.backdrop?e._triggerBackdropTransition():e.hide())})),n&&f.reflow(this._backdrop),p.default(this._backdrop).addClass(un),!t)return;if(!n)return void t();var o=f.getTransitionDurationFromElement(this._backdrop);p.default(this._backdrop).one(f.TRANSITION_END,t).emulateTransitionEnd(o)}else if(!this._isShown&&this._backdrop){p.default(this._backdrop).removeClass(un);var M=function(){e._removeBackdrop(),t&&t()};if(p.default(this._element).hasClass(An)){var b=f.getTransitionDurationFromElement(this._backdrop);p.default(this._backdrop).one(f.TRANSITION_END,M).emulateTransitionEnd(b)}else M()}else t&&t()},e._adjustDialog=function(){var t=this._element.scrollHeight>document.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},e._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},e._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=Math.round(t.left+t.right)
      ',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent",customClass:"",sanitize:!0,sanitizeFn:null,whiteList:In,popperConfig:null},ao={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(number|string|function)",container:"(string|element|boolean)",fallbackPlacement:"(string|array)",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",whiteList:"object",popperConfig:"(null|object)"},io={HIDE:"hide"+Yn,HIDDEN:"hidden"+Yn,SHOW:"show"+Yn,SHOWN:"shown"+Yn,INSERTED:"inserted"+Yn,CLICK:"click"+Yn,FOCUSIN:"focusin"+Yn,FOCUSOUT:"focusout"+Yn,MOUSEENTER:"mouseenter"+Yn,MOUSELEAVE:"mouseleave"+Yn},Oo=function(){function t(t,e){if(void 0===M.default)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var e=t.prototype;return e.enable=function(){this._isEnabled=!0},e.disable=function(){this._isEnabled=!1},e.toggleEnabled=function(){this._isEnabled=!this._isEnabled},e.toggle=function(t){if(this._isEnabled)if(t){var e=this.constructor.DATA_KEY,n=p.default(t.currentTarget).data(e);n||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),p.default(t.currentTarget).data(e,n)),n._activeTrigger.click=!n._activeTrigger.click,n._isWithActiveTrigger()?n._enter(null,n):n._leave(null,n)}else{if(p.default(this.getTipElement()).hasClass(Zn))return void this._leave(null,this);this._enter(null,this)}},e.dispose=function(){clearTimeout(this._timeout),p.default.removeData(this.element,this.constructor.DATA_KEY),p.default(this.element).off(this.constructor.EVENT_KEY),p.default(this.element).closest(".modal").off("hide.bs.modal",this._hideModalHandler),this.tip&&p.default(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,this._activeTrigger=null,this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},e.show=function(){var t=this;if("none"===p.default(this.element).css("display"))throw new Error("Please use show on visible elements");var e=p.default.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){p.default(this.element).trigger(e);var n=f.findShadowRoot(this.element),o=p.default.contains(null!==n?n:this.element.ownerDocument.documentElement,this.element);if(e.isDefaultPrevented()||!o)return;var b=this.getTipElement(),c=f.getUID(this.constructor.NAME);b.setAttribute("id",c),this.element.setAttribute("aria-describedby",c),this.setContent(),this.config.animation&&p.default(b).addClass(Jn);var r="function"==typeof this.config.placement?this.config.placement.call(this,b,this.element):this.config.placement,z=this._getAttachment(r);this.addAttachmentClass(z);var a=this._getContainer();p.default(b).data(this.constructor.DATA_KEY,this),p.default.contains(this.element.ownerDocument.documentElement,this.tip)||p.default(b).appendTo(a),p.default(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new M.default(this.element,b,this._getPopperConfig(z)),p.default(b).addClass(Zn),p.default(b).addClass(this.config.customClass),"ontouchstart"in document.documentElement&&p.default(document.body).children().on("mouseover",null,p.default.noop);var i=function(){t.config.animation&&t._fixTransition();var e=t._hoverState;t._hoverState=null,p.default(t.element).trigger(t.constructor.Event.SHOWN),e===eo&&t._leave(null,t)};if(p.default(this.tip).hasClass(Jn)){var O=f.getTransitionDurationFromElement(this.tip);p.default(this.tip).one(f.TRANSITION_END,i).emulateTransitionEnd(O)}else i()}},e.hide=function(t){var e=this,n=this.getTipElement(),o=p.default.Event(this.constructor.Event.HIDE),M=function(){e._hoverState!==to&&n.parentNode&&n.parentNode.removeChild(n),e._cleanTipClass(),e.element.removeAttribute("aria-describedby"),p.default(e.element).trigger(e.constructor.Event.HIDDEN),null!==e._popper&&e._popper.destroy(),t&&t()};if(p.default(this.element).trigger(o),!o.isDefaultPrevented()){if(p.default(n).removeClass(Zn),"ontouchstart"in document.documentElement&&p.default(document.body).children().off("mouseover",null,p.default.noop),this._activeTrigger[bo]=!1,this._activeTrigger[Mo]=!1,this._activeTrigger[po]=!1,p.default(this.tip).hasClass(Jn)){var b=f.getTransitionDurationFromElement(n);p.default(n).one(f.TRANSITION_END,M).emulateTransitionEnd(b)}else M();this._hoverState=""}},e.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},e.isWithContent=function(){return Boolean(this.getTitle())},e.addAttachmentClass=function(t){p.default(this.getTipElement()).addClass(Vn+"-"+t)},e.getTipElement=function(){return this.tip=this.tip||p.default(this.config.template)[0],this.tip},e.setContent=function(){var t=this.getTipElement();this.setElementContent(p.default(t.querySelectorAll(no)),this.getTitle()),p.default(t).removeClass(Jn+" "+Zn)},e.setElementContent=function(t,e){"object"!=typeof e||!e.nodeType&&!e.jquery?this.config.html?(this.config.sanitize&&(e=jn(e,this.config.whiteList,this.config.sanitizeFn)),t.html(e)):t.text(e):this.config.html?p.default(e).parent().is(t)||t.empty().append(e):t.text(p.default(e).text())},e.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},e._getPopperConfig=function(t){var e=this;return r({},{placement:t,modifiers:{offset:this._getOffset(),flip:{behavior:this.config.fallbackPlacement},arrow:{element:oo},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){return e._handlePopperPlacementChange(t)}},this.config.popperConfig)},e._getOffset=function(){var t=this,e={};return"function"==typeof this.config.offset?e.fn=function(e){return e.offsets=r({},e.offsets,t.config.offset(e.offsets,t.element)),e}:e.offset=this.config.offset,e},e._getContainer=function(){return!1===this.config.container?document.body:f.isElement(this.config.container)?p.default(this.config.container):p.default(document).find(this.config.container)},e._getAttachment=function(t){return ro[t.toUpperCase()]},e._setListeners=function(){var t=this;this.config.trigger.split(" ").forEach((function(e){if("click"===e)p.default(t.element).on(t.constructor.Event.CLICK,t.config.selector,(function(e){return t.toggle(e)}));else if(e!==co){var n=e===po?t.constructor.Event.MOUSEENTER:t.constructor.Event.FOCUSIN,o=e===po?t.constructor.Event.MOUSELEAVE:t.constructor.Event.FOCUSOUT;p.default(t.element).on(n,t.config.selector,(function(e){return t._enter(e)})).on(o,t.config.selector,(function(e){return t._leave(e)}))}})),this._hideModalHandler=function(){t.element&&t.hide()},p.default(this.element).closest(".modal").on("hide.bs.modal",this._hideModalHandler),this.config.selector?this.config=r({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},e._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},e._enter=function(t,e){var n=this.constructor.DATA_KEY;(e=e||p.default(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),p.default(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusin"===t.type?Mo:po]=!0),p.default(e.getTipElement()).hasClass(Zn)||e._hoverState===to?e._hoverState=to:(clearTimeout(e._timeout),e._hoverState=to,e.config.delay&&e.config.delay.show?e._timeout=setTimeout((function(){e._hoverState===to&&e.show()}),e.config.delay.show):e.show())},e._leave=function(t,e){var n=this.constructor.DATA_KEY;(e=e||p.default(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),p.default(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusout"===t.type?Mo:po]=!1),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=eo,e.config.delay&&e.config.delay.hide?e._timeout=setTimeout((function(){e._hoverState===eo&&e.hide()}),e.config.delay.hide):e.hide())},e._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},e._getConfig=function(t){var e=p.default(this.element).data();return Object.keys(e).forEach((function(t){-1!==Qn.indexOf(t)&&delete e[t]})),"number"==typeof(t=r({},this.constructor.Default,e,"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),f.typeCheckConfig(Hn,t,this.constructor.DefaultType),t.sanitize&&(t.template=jn(t.template,t.whiteList,t.sanitizeFn)),t},e._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},e._cleanTipClass=function(){var t=p.default(this.getTipElement()),e=t.attr("class").match(Kn);null!==e&&e.length&&t.removeClass(e.join(""))},e._handlePopperPlacementChange=function(t){this.tip=t.instance.popper,this._cleanTipClass(),this.addAttachmentClass(this._getAttachment(t.placement))},e._fixTransition=function(){var t=this.getTipElement(),e=this.config.animation;null===t.getAttribute("x-placement")&&(p.default(t).removeClass(Jn),this.config.animation=!1,this.hide(),this.show(),this.config.animation=e)},t._jQueryInterface=function(e){return this.each((function(){var n=p.default(this),o=n.data(Gn),M="object"==typeof e&&e;if((o||!/dispose|hide/.test(e))&&(o||(o=new t(this,M),n.data(Gn,o)),"string"==typeof e)){if(void 0===o[e])throw new TypeError('No method named "'+e+'"');o[e]()}}))},c(t,null,[{key:"VERSION",get:function(){return Fn}},{key:"Default",get:function(){return zo}},{key:"NAME",get:function(){return Hn}},{key:"DATA_KEY",get:function(){return Gn}},{key:"Event",get:function(){return io}},{key:"EVENT_KEY",get:function(){return Yn}},{key:"DefaultType",get:function(){return ao}}]),t}();p.default.fn[Hn]=Oo._jQueryInterface,p.default.fn[Hn].Constructor=Oo,p.default.fn[Hn].noConflict=function(){return p.default.fn[Hn]=$n,Oo._jQueryInterface};var so="popover",Ao="4.6.2",uo="bs.popover",lo="."+uo,fo=p.default.fn[so],qo="bs-popover",ho=new RegExp("(^|\\s)"+qo+"\\S+","g"),Wo="fade",vo="show",Ro=".popover-header",mo=".popover-body",go=r({},Oo.Default,{placement:"right",trigger:"click",content:"",template:''}),Lo=r({},Oo.DefaultType,{content:"(string|element|function)"}),yo={HIDE:"hide"+lo,HIDDEN:"hidden"+lo,SHOW:"show"+lo,SHOWN:"shown"+lo,INSERTED:"inserted"+lo,CLICK:"click"+lo,FOCUSIN:"focusin"+lo,FOCUSOUT:"focusout"+lo,MOUSEENTER:"mouseenter"+lo,MOUSELEAVE:"mouseleave"+lo},_o=function(t){function e(){return t.apply(this,arguments)||this}z(e,t);var n=e.prototype;return n.isWithContent=function(){return this.getTitle()||this._getContent()},n.addAttachmentClass=function(t){p.default(this.getTipElement()).addClass(qo+"-"+t)},n.getTipElement=function(){return this.tip=this.tip||p.default(this.config.template)[0],this.tip},n.setContent=function(){var t=p.default(this.getTipElement());this.setElementContent(t.find(Ro),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(t.find(mo),e),t.removeClass(Wo+" "+vo)},n._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},n._cleanTipClass=function(){var t=p.default(this.getTipElement()),e=t.attr("class").match(ho);null!==e&&e.length>0&&t.removeClass(e.join(""))},e._jQueryInterface=function(t){return this.each((function(){var n=p.default(this).data(uo),o="object"==typeof t?t:null;if((n||!/dispose|hide/.test(t))&&(n||(n=new e(this,o),p.default(this).data(uo,n)),"string"==typeof t)){if(void 0===n[t])throw new TypeError('No method named "'+t+'"');n[t]()}}))},c(e,null,[{key:"VERSION",get:function(){return Ao}},{key:"Default",get:function(){return go}},{key:"NAME",get:function(){return so}},{key:"DATA_KEY",get:function(){return uo}},{key:"Event",get:function(){return yo}},{key:"EVENT_KEY",get:function(){return lo}},{key:"DefaultType",get:function(){return Lo}}]),e}(Oo);p.default.fn[so]=_o._jQueryInterface,p.default.fn[so].Constructor=_o,p.default.fn[so].noConflict=function(){return p.default.fn[so]=fo,_o._jQueryInterface};var No="scrollspy",Eo="4.6.2",To="bs.scrollspy",Bo="."+To,Co=".data-api",wo=p.default.fn[No],So="dropdown-item",Xo="active",xo="activate"+Bo,ko="scroll"+Bo,Io="load"+Bo+Co,Do="offset",Po="position",Uo='[data-spy="scroll"]',jo=".nav, .list-group",Ho=".nav-link",Fo=".nav-item",Go=".list-group-item",Yo=".dropdown",$o=".dropdown-item",Vo=".dropdown-toggle",Ko={offset:10,method:"auto",target:""},Qo={offset:"number",method:"string",target:"(string|element)"},Jo=function(){function t(t,e){var n=this;this._element=t,this._scrollElement="BODY"===t.tagName?window:t,this._config=this._getConfig(e),this._selector=this._config.target+" "+Ho+","+this._config.target+" "+Go+","+this._config.target+" "+$o,this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,p.default(this._scrollElement).on(ko,(function(t){return n._process(t)})),this.refresh(),this._process()}var e=t.prototype;return e.refresh=function(){var t=this,e=this._scrollElement===this._scrollElement.window?Do:Po,n="auto"===this._config.method?e:this._config.method,o=n===Po?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),[].slice.call(document.querySelectorAll(this._selector)).map((function(t){var e,M=f.getSelectorFromElement(t);if(M&&(e=document.querySelector(M)),e){var b=e.getBoundingClientRect();if(b.width||b.height)return[p.default(e)[n]().top+o,M]}return null})).filter(Boolean).sort((function(t,e){return t[0]-e[0]})).forEach((function(e){t._offsets.push(e[0]),t._targets.push(e[1])}))},e.dispose=function(){p.default.removeData(this._element,To),p.default(this._scrollElement).off(Bo),this._element=null,this._scrollElement=null,this._config=null,this._selector=null,this._offsets=null,this._targets=null,this._activeTarget=null,this._scrollHeight=null},e._getConfig=function(t){if("string"!=typeof(t=r({},Ko,"object"==typeof t&&t?t:{})).target&&f.isElement(t.target)){var e=p.default(t.target).attr("id");e||(e=f.getUID(No),p.default(t.target).attr("id",e)),t.target="#"+e}return f.typeCheckConfig(No,t,Qo),t},e._getScrollTop=function(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop},e._getScrollHeight=function(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)},e._getOffsetHeight=function(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height},e._process=function(){var t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),n=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=n){var o=this._targets[this._targets.length-1];this._activeTarget!==o&&this._activate(o)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(var p=this._offsets.length;p--;)this._activeTarget!==this._targets[p]&&t>=this._offsets[p]&&(void 0===this._offsets[p+1]||t{"use strict";var o=n(7526),p=n(251),M=n(4634);function b(){return r.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function c(t,e){if(b()=b())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+b().toString(16)+" bytes");return 0|t}function A(t,e){if(r.isBuffer(t))return t.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(t)||t instanceof ArrayBuffer))return t.byteLength;"string"!=typeof t&&(t=""+t);var n=t.length;if(0===n)return 0;for(var o=!1;;)switch(e){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":case void 0:return P(t).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return U(t).length;default:if(o)return P(t).length;e=(""+e).toLowerCase(),o=!0}}function u(t,e,n){var o=!1;if((void 0===e||e<0)&&(e=0),e>this.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if((n>>>=0)<=(e>>>=0))return"";for(t||(t="utf8");;)switch(t){case"hex":return E(this,e,n);case"utf8":case"utf-8":return L(this,e,n);case"ascii":return _(this,e,n);case"latin1":case"binary":return N(this,e,n);case"base64":return g(this,e,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return T(this,e,n);default:if(o)throw new TypeError("Unknown encoding: "+t);t=(t+"").toLowerCase(),o=!0}}function l(t,e,n){var o=t[e];t[e]=t[n],t[n]=o}function d(t,e,n,o,p){if(0===t.length)return-1;if("string"==typeof n?(o=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),n=+n,isNaN(n)&&(n=p?0:t.length-1),n<0&&(n=t.length+n),n>=t.length){if(p)return-1;n=t.length-1}else if(n<0){if(!p)return-1;n=0}if("string"==typeof e&&(e=r.from(e,o)),r.isBuffer(e))return 0===e.length?-1:f(t,e,n,o,p);if("number"==typeof e)return e&=255,r.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?p?Uint8Array.prototype.indexOf.call(t,e,n):Uint8Array.prototype.lastIndexOf.call(t,e,n):f(t,[e],n,o,p);throw new TypeError("val must be string, number or Buffer")}function f(t,e,n,o,p){var M,b=1,c=t.length,r=e.length;if(void 0!==o&&("ucs2"===(o=String(o).toLowerCase())||"ucs-2"===o||"utf16le"===o||"utf-16le"===o)){if(t.length<2||e.length<2)return-1;b=2,c/=2,r/=2,n/=2}function z(t,e){return 1===b?t[e]:t.readUInt16BE(e*b)}if(p){var a=-1;for(M=n;Mc&&(n=c-r),M=n;M>=0;M--){for(var i=!0,O=0;Op&&(o=p):o=p;var M=e.length;if(M%2!=0)throw new TypeError("Invalid hex string");o>M/2&&(o=M/2);for(var b=0;b>8,p=n%256,M.push(p),M.push(o);return M}(e,t.length-n),t,n,o)}function g(t,e,n){return 0===e&&n===t.length?o.fromByteArray(t):o.fromByteArray(t.slice(e,n))}function L(t,e,n){n=Math.min(t.length,n);for(var o=[],p=e;p239?4:z>223?3:z>191?2:1;if(p+i<=n)switch(i){case 1:z<128&&(a=z);break;case 2:128==(192&(M=t[p+1]))&&(r=(31&z)<<6|63&M)>127&&(a=r);break;case 3:M=t[p+1],b=t[p+2],128==(192&M)&&128==(192&b)&&(r=(15&z)<<12|(63&M)<<6|63&b)>2047&&(r<55296||r>57343)&&(a=r);break;case 4:M=t[p+1],b=t[p+2],c=t[p+3],128==(192&M)&&128==(192&b)&&128==(192&c)&&(r=(15&z)<<18|(63&M)<<12|(63&b)<<6|63&c)>65535&&r<1114112&&(a=r)}null===a?(a=65533,i=1):a>65535&&(a-=65536,o.push(a>>>10&1023|55296),a=56320|1023&a),o.push(a),p+=i}return function(t){var e=t.length;if(e<=y)return String.fromCharCode.apply(String,t);var n="",o=0;for(;o0&&(t=this.toString("hex",0,n).match(/.{2}/g).join(" "),this.length>n&&(t+=" ... ")),""},r.prototype.compare=function(t,e,n,o,p){if(!r.isBuffer(t))throw new TypeError("Argument must be a Buffer");if(void 0===e&&(e=0),void 0===n&&(n=t?t.length:0),void 0===o&&(o=0),void 0===p&&(p=this.length),e<0||n>t.length||o<0||p>this.length)throw new RangeError("out of range index");if(o>=p&&e>=n)return 0;if(o>=p)return-1;if(e>=n)return 1;if(this===t)return 0;for(var M=(p>>>=0)-(o>>>=0),b=(n>>>=0)-(e>>>=0),c=Math.min(M,b),z=this.slice(o,p),a=t.slice(e,n),i=0;ip)&&(n=p),t.length>0&&(n<0||e<0)||e>this.length)throw new RangeError("Attempt to write outside buffer bounds");o||(o="utf8");for(var M=!1;;)switch(o){case"hex":return q(this,t,e,n);case"utf8":case"utf-8":return h(this,t,e,n);case"ascii":return W(this,t,e,n);case"latin1":case"binary":return v(this,t,e,n);case"base64":return R(this,t,e,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return m(this,t,e,n);default:if(M)throw new TypeError("Unknown encoding: "+o);o=(""+o).toLowerCase(),M=!0}},r.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var y=4096;function _(t,e,n){var o="";n=Math.min(t.length,n);for(var p=e;po)&&(n=o);for(var p="",M=e;Mn)throw new RangeError("Trying to access beyond buffer length")}function C(t,e,n,o,p,M){if(!r.isBuffer(t))throw new TypeError('"buffer" argument must be a Buffer instance');if(e>p||et.length)throw new RangeError("Index out of range")}function w(t,e,n,o){e<0&&(e=65535+e+1);for(var p=0,M=Math.min(t.length-n,2);p>>8*(o?p:1-p)}function S(t,e,n,o){e<0&&(e=4294967295+e+1);for(var p=0,M=Math.min(t.length-n,4);p>>8*(o?p:3-p)&255}function X(t,e,n,o,p,M){if(n+o>t.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function x(t,e,n,o,M){return M||X(t,0,n,4),p.write(t,e,n,o,23,4),n+4}function k(t,e,n,o,M){return M||X(t,0,n,8),p.write(t,e,n,o,52,8),n+8}r.prototype.slice=function(t,e){var n,o=this.length;if((t=~~t)<0?(t+=o)<0&&(t=0):t>o&&(t=o),(e=void 0===e?o:~~e)<0?(e+=o)<0&&(e=0):e>o&&(e=o),e0&&(p*=256);)o+=this[t+--e]*p;return o},r.prototype.readUInt8=function(t,e){return e||B(t,1,this.length),this[t]},r.prototype.readUInt16LE=function(t,e){return e||B(t,2,this.length),this[t]|this[t+1]<<8},r.prototype.readUInt16BE=function(t,e){return e||B(t,2,this.length),this[t]<<8|this[t+1]},r.prototype.readUInt32LE=function(t,e){return e||B(t,4,this.length),(this[t]|this[t+1]<<8|this[t+2]<<16)+16777216*this[t+3]},r.prototype.readUInt32BE=function(t,e){return e||B(t,4,this.length),16777216*this[t]+(this[t+1]<<16|this[t+2]<<8|this[t+3])},r.prototype.readIntLE=function(t,e,n){t|=0,e|=0,n||B(t,e,this.length);for(var o=this[t],p=1,M=0;++M=(p*=128)&&(o-=Math.pow(2,8*e)),o},r.prototype.readIntBE=function(t,e,n){t|=0,e|=0,n||B(t,e,this.length);for(var o=e,p=1,M=this[t+--o];o>0&&(p*=256);)M+=this[t+--o]*p;return M>=(p*=128)&&(M-=Math.pow(2,8*e)),M},r.prototype.readInt8=function(t,e){return e||B(t,1,this.length),128&this[t]?-1*(255-this[t]+1):this[t]},r.prototype.readInt16LE=function(t,e){e||B(t,2,this.length);var n=this[t]|this[t+1]<<8;return 32768&n?4294901760|n:n},r.prototype.readInt16BE=function(t,e){e||B(t,2,this.length);var n=this[t+1]|this[t]<<8;return 32768&n?4294901760|n:n},r.prototype.readInt32LE=function(t,e){return e||B(t,4,this.length),this[t]|this[t+1]<<8|this[t+2]<<16|this[t+3]<<24},r.prototype.readInt32BE=function(t,e){return e||B(t,4,this.length),this[t]<<24|this[t+1]<<16|this[t+2]<<8|this[t+3]},r.prototype.readFloatLE=function(t,e){return e||B(t,4,this.length),p.read(this,t,!0,23,4)},r.prototype.readFloatBE=function(t,e){return e||B(t,4,this.length),p.read(this,t,!1,23,4)},r.prototype.readDoubleLE=function(t,e){return e||B(t,8,this.length),p.read(this,t,!0,52,8)},r.prototype.readDoubleBE=function(t,e){return e||B(t,8,this.length),p.read(this,t,!1,52,8)},r.prototype.writeUIntLE=function(t,e,n,o){(t=+t,e|=0,n|=0,o)||C(this,t,e,n,Math.pow(2,8*n)-1,0);var p=1,M=0;for(this[e]=255&t;++M=0&&(M*=256);)this[e+p]=t/M&255;return e+n},r.prototype.writeUInt8=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,1,255,0),r.TYPED_ARRAY_SUPPORT||(t=Math.floor(t)),this[e]=255&t,e+1},r.prototype.writeUInt16LE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,2,65535,0),r.TYPED_ARRAY_SUPPORT?(this[e]=255&t,this[e+1]=t>>>8):w(this,t,e,!0),e+2},r.prototype.writeUInt16BE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,2,65535,0),r.TYPED_ARRAY_SUPPORT?(this[e]=t>>>8,this[e+1]=255&t):w(this,t,e,!1),e+2},r.prototype.writeUInt32LE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,4,4294967295,0),r.TYPED_ARRAY_SUPPORT?(this[e+3]=t>>>24,this[e+2]=t>>>16,this[e+1]=t>>>8,this[e]=255&t):S(this,t,e,!0),e+4},r.prototype.writeUInt32BE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,4,4294967295,0),r.TYPED_ARRAY_SUPPORT?(this[e]=t>>>24,this[e+1]=t>>>16,this[e+2]=t>>>8,this[e+3]=255&t):S(this,t,e,!1),e+4},r.prototype.writeIntLE=function(t,e,n,o){if(t=+t,e|=0,!o){var p=Math.pow(2,8*n-1);C(this,t,e,n,p-1,-p)}var M=0,b=1,c=0;for(this[e]=255&t;++M=0&&(b*=256);)t<0&&0===c&&0!==this[e+M+1]&&(c=1),this[e+M]=(t/b|0)-c&255;return e+n},r.prototype.writeInt8=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,1,127,-128),r.TYPED_ARRAY_SUPPORT||(t=Math.floor(t)),t<0&&(t=255+t+1),this[e]=255&t,e+1},r.prototype.writeInt16LE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,2,32767,-32768),r.TYPED_ARRAY_SUPPORT?(this[e]=255&t,this[e+1]=t>>>8):w(this,t,e,!0),e+2},r.prototype.writeInt16BE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,2,32767,-32768),r.TYPED_ARRAY_SUPPORT?(this[e]=t>>>8,this[e+1]=255&t):w(this,t,e,!1),e+2},r.prototype.writeInt32LE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,4,2147483647,-2147483648),r.TYPED_ARRAY_SUPPORT?(this[e]=255&t,this[e+1]=t>>>8,this[e+2]=t>>>16,this[e+3]=t>>>24):S(this,t,e,!0),e+4},r.prototype.writeInt32BE=function(t,e,n){return t=+t,e|=0,n||C(this,t,e,4,2147483647,-2147483648),t<0&&(t=4294967295+t+1),r.TYPED_ARRAY_SUPPORT?(this[e]=t>>>24,this[e+1]=t>>>16,this[e+2]=t>>>8,this[e+3]=255&t):S(this,t,e,!1),e+4},r.prototype.writeFloatLE=function(t,e,n){return x(this,t,e,!0,n)},r.prototype.writeFloatBE=function(t,e,n){return x(this,t,e,!1,n)},r.prototype.writeDoubleLE=function(t,e,n){return k(this,t,e,!0,n)},r.prototype.writeDoubleBE=function(t,e,n){return k(this,t,e,!1,n)},r.prototype.copy=function(t,e,n,o){if(n||(n=0),o||0===o||(o=this.length),e>=t.length&&(e=t.length),e||(e=0),o>0&&o=this.length)throw new RangeError("sourceStart out of bounds");if(o<0)throw new RangeError("sourceEnd out of bounds");o>this.length&&(o=this.length),t.length-e=0;--p)t[p+e]=this[p+n];else if(M<1e3||!r.TYPED_ARRAY_SUPPORT)for(p=0;p>>=0,n=void 0===n?this.length:n>>>0,t||(t=0),"number"==typeof t)for(M=e;M55295&&n<57344){if(!p){if(n>56319){(e-=3)>-1&&M.push(239,191,189);continue}if(b+1===o){(e-=3)>-1&&M.push(239,191,189);continue}p=n;continue}if(n<56320){(e-=3)>-1&&M.push(239,191,189),p=n;continue}n=65536+(p-55296<<10|n-56320)}else p&&(e-=3)>-1&&M.push(239,191,189);if(p=null,n<128){if((e-=1)<0)break;M.push(n)}else if(n<2048){if((e-=2)<0)break;M.push(n>>6|192,63&n|128)}else if(n<65536){if((e-=3)<0)break;M.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((e-=4)<0)break;M.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return M}function U(t){return o.toByteArray(function(t){if((t=function(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}(t).replace(I,"")).length<2)return"";for(;t.length%4!=0;)t+="=";return t}(t))}function j(t,e,n,o){for(var p=0;p=e.length||p>=t.length);++p)e[p+n]=t[p];return p}},7965:(t,e,n)=>{"use strict";var o=n(6426),p={"text/plain":"Text","text/html":"Url",default:"Text"};t.exports=function(t,e){var n,M,b,c,r,z=!1;e||(e={}),e.debug;try{if(M=o(),b=document.createRange(),c=document.getSelection(),(r=document.createElement("span")).textContent=t,r.ariaHidden="true",r.style.all="unset",r.style.position="fixed",r.style.top=0,r.style.clip="rect(0, 0, 0, 0)",r.style.whiteSpace="pre",r.style.webkitUserSelect="text",r.style.MozUserSelect="text",r.style.msUserSelect="text",r.style.userSelect="text",r.addEventListener("copy",(function(n){if(n.stopPropagation(),e.format)if(n.preventDefault(),void 0===n.clipboardData){window.clipboardData.clearData();var o=p[e.format]||p.default;window.clipboardData.setData(o,t)}else n.clipboardData.clearData(),n.clipboardData.setData(e.format,t);e.onCopy&&(n.preventDefault(),e.onCopy(n.clipboardData))})),document.body.appendChild(r),b.selectNodeContents(r),c.addRange(b),!document.execCommand("copy"))throw new Error("copy command was unsuccessful");z=!0}catch(o){try{window.clipboardData.setData(e.format||"text",t),e.onCopy&&e.onCopy(window.clipboardData),z=!0}catch(o){n=function(t){var e=(/mac os x/i.test(navigator.userAgent)?"⌘":"Ctrl")+"+C";return t.replace(/#{\s*key\s*}/g,e)}("message"in e?e.message:"Copy to clipboard: #{key}, Enter"),window.prompt(n,t)}}finally{c&&("function"==typeof c.removeRange?c.removeRange(b):c.removeAllRanges()),r&&document.body.removeChild(r),M()}return z}},251:(t,e)=>{e.read=function(t,e,n,o,p){var M,b,c=8*p-o-1,r=(1<>1,a=-7,i=n?p-1:0,O=n?-1:1,s=t[e+i];for(i+=O,M=s&(1<<-a)-1,s>>=-a,a+=c;a>0;M=256*M+t[e+i],i+=O,a-=8);for(b=M&(1<<-a)-1,M>>=-a,a+=o;a>0;b=256*b+t[e+i],i+=O,a-=8);if(0===M)M=1-z;else{if(M===r)return b?NaN:1/0*(s?-1:1);b+=Math.pow(2,o),M-=z}return(s?-1:1)*b*Math.pow(2,M-o)},e.write=function(t,e,n,o,p,M){var b,c,r,z=8*M-p-1,a=(1<>1,O=23===p?Math.pow(2,-24)-Math.pow(2,-77):0,s=o?0:M-1,A=o?1:-1,u=e<0||0===e&&1/e<0?1:0;for(e=Math.abs(e),isNaN(e)||e===1/0?(c=isNaN(e)?1:0,b=a):(b=Math.floor(Math.log(e)/Math.LN2),e*(r=Math.pow(2,-b))<1&&(b--,r*=2),(e+=b+i>=1?O/r:O*Math.pow(2,1-i))*r>=2&&(b++,r/=2),b+i>=a?(c=0,b=a):b+i>=1?(c=(e*r-1)*Math.pow(2,p),b+=i):(c=e*Math.pow(2,i-1)*Math.pow(2,p),b=0));p>=8;t[n+s]=255&c,s+=A,c/=256,p-=8);for(b=b<0;t[n+s]=255&b,s+=A,b/=256,z-=8);t[n+s-A]|=128*u}},4634:t=>{var e={}.toString;t.exports=Array.isArray||function(t){return"[object Array]"==e.call(t)}},4692:function(t,e){var n;!function(e,n){"use strict";"object"==typeof t.exports?t.exports=e.document?n(e,!0):function(t){if(!t.document)throw new Error("jQuery requires a window with a document");return n(t)}:n(e)}("undefined"!=typeof window?window:this,(function(o,p){"use strict";var M=[],b=Object.getPrototypeOf,c=M.slice,r=M.flat?function(t){return M.flat.call(t)}:function(t){return M.concat.apply([],t)},z=M.push,a=M.indexOf,i={},O=i.toString,s=i.hasOwnProperty,A=s.toString,u=A.call(Object),l={},d=function(t){return"function"==typeof t&&"number"!=typeof t.nodeType&&"function"!=typeof t.item},f=function(t){return null!=t&&t===t.window},q=o.document,h={type:!0,src:!0,nonce:!0,noModule:!0};function W(t,e,n){var o,p,M=(n=n||q).createElement("script");if(M.text=t,e)for(o in h)(p=e[o]||e.getAttribute&&e.getAttribute(o))&&M.setAttribute(o,p);n.head.appendChild(M).parentNode.removeChild(M)}function v(t){return null==t?t+"":"object"==typeof t||"function"==typeof t?i[O.call(t)]||"object":typeof t}var R="3.7.1",m=/HTML$/i,g=function(t,e){return new g.fn.init(t,e)};function L(t){var e=!!t&&"length"in t&&t.length,n=v(t);return!d(t)&&!f(t)&&("array"===n||0===e||"number"==typeof e&&e>0&&e-1 in t)}function y(t,e){return t.nodeName&&t.nodeName.toLowerCase()===e.toLowerCase()}g.fn=g.prototype={jquery:R,constructor:g,length:0,toArray:function(){return c.call(this)},get:function(t){return null==t?c.call(this):t<0?this[t+this.length]:this[t]},pushStack:function(t){var e=g.merge(this.constructor(),t);return e.prevObject=this,e},each:function(t){return g.each(this,t)},map:function(t){return this.pushStack(g.map(this,(function(e,n){return t.call(e,n,e)})))},slice:function(){return this.pushStack(c.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(g.grep(this,(function(t,e){return(e+1)%2})))},odd:function(){return this.pushStack(g.grep(this,(function(t,e){return e%2})))},eq:function(t){var e=this.length,n=+t+(t<0?e:0);return this.pushStack(n>=0&&n+~]|"+T+")"+T+"*"),P=new RegExp(T+"|>"),U=new RegExp(x),j=new RegExp("^"+C+"$"),H={ID:new RegExp("^#("+C+")"),CLASS:new RegExp("^\\.("+C+")"),TAG:new RegExp("^("+C+"|[*])"),ATTR:new RegExp("^"+w),PSEUDO:new RegExp("^"+x),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+T+"*(even|odd|(([+-]|)(\\d*)n|)"+T+"*(?:([+-]|)"+T+"*(\\d+)|))"+T+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+T+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+T+"*((?:-\\d)?\\d*)"+T+"*\\)|)(?=[^-]|$)","i")},F=/^(?:input|select|textarea|button)$/i,G=/^h\d$/i,Y=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,V=new RegExp("\\\\[\\da-fA-F]{1,6}"+T+"?|\\\\([^\\r\\n\\f])","g"),K=function(t,e){var n="0x"+t.slice(1)-65536;return e||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},Q=function(){rt()},J=Ot((function(t){return!0===t.disabled&&y(t,"fieldset")}),{dir:"parentNode",next:"legend"});try{u.apply(M=c.call(S.childNodes),S.childNodes),M[S.childNodes.length].nodeType}catch(t){u={apply:function(t,e){X.apply(t,c.call(e))},call:function(t){X.apply(t,c.call(arguments,1))}}}function Z(t,e,n,o){var p,M,b,c,z,a,s,A=e&&e.ownerDocument,f=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==f&&9!==f&&11!==f)return n;if(!o&&(rt(e),e=e||r,i)){if(11!==f&&(z=Y.exec(t)))if(p=z[1]){if(9===f){if(!(b=e.getElementById(p)))return n;if(b.id===p)return u.call(n,b),n}else if(A&&(b=A.getElementById(p))&&Z.contains(e,b)&&b.id===p)return u.call(n,b),n}else{if(z[2])return u.apply(n,e.getElementsByTagName(t)),n;if((p=z[3])&&e.getElementsByClassName)return u.apply(n,e.getElementsByClassName(p)),n}if(!(R[t+" "]||O&&O.test(t))){if(s=t,A=e,1===f&&(P.test(t)||D.test(t))){for((A=$.test(t)&&ct(e.parentNode)||e)==e&&l.scope||((c=e.getAttribute("id"))?c=g.escapeSelector(c):e.setAttribute("id",c=d)),M=(a=at(t)).length;M--;)a[M]=(c?"#"+c:":scope")+" "+it(a[M]);s=a.join(",")}try{return u.apply(n,A.querySelectorAll(s)),n}catch(e){R(t,!0)}finally{c===d&&e.removeAttribute("id")}}}return ft(t.replace(B,"$1"),e,n,o)}function tt(){var t=[];return function n(o,p){return t.push(o+" ")>e.cacheLength&&delete n[t.shift()],n[o+" "]=p}}function et(t){return t[d]=!0,t}function nt(t){var e=r.createElement("fieldset");try{return!!t(e)}catch(t){return!1}finally{e.parentNode&&e.parentNode.removeChild(e),e=null}}function ot(t){return function(e){return y(e,"input")&&e.type===t}}function pt(t){return function(e){return(y(e,"input")||y(e,"button"))&&e.type===t}}function Mt(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&J(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function bt(t){return et((function(e){return e=+e,et((function(n,o){for(var p,M=t([],n.length,e),b=M.length;b--;)n[p=M[b]]&&(n[p]=!(o[p]=n[p]))}))}))}function ct(t){return t&&void 0!==t.getElementsByTagName&&t}function rt(t){var n,o=t?t.ownerDocument||t:S;return o!=r&&9===o.nodeType&&o.documentElement?(z=(r=o).documentElement,i=!g.isXMLDoc(r),A=z.matches||z.webkitMatchesSelector||z.msMatchesSelector,z.msMatchesSelector&&S!=r&&(n=r.defaultView)&&n.top!==n&&n.addEventListener("unload",Q),l.getById=nt((function(t){return z.appendChild(t).id=g.expando,!r.getElementsByName||!r.getElementsByName(g.expando).length})),l.disconnectedMatch=nt((function(t){return A.call(t,"*")})),l.scope=nt((function(){return r.querySelectorAll(":scope")})),l.cssHas=nt((function(){try{return r.querySelector(":has(*,:jqfake)"),!1}catch(t){return!0}})),l.getById?(e.filter.ID=function(t){var e=t.replace(V,K);return function(t){return t.getAttribute("id")===e}},e.find.ID=function(t,e){if(void 0!==e.getElementById&&i){var n=e.getElementById(t);return n?[n]:[]}}):(e.filter.ID=function(t){var e=t.replace(V,K);return function(t){var n=void 0!==t.getAttributeNode&&t.getAttributeNode("id");return n&&n.value===e}},e.find.ID=function(t,e){if(void 0!==e.getElementById&&i){var n,o,p,M=e.getElementById(t);if(M){if((n=M.getAttributeNode("id"))&&n.value===t)return[M];for(p=e.getElementsByName(t),o=0;M=p[o++];)if((n=M.getAttributeNode("id"))&&n.value===t)return[M]}return[]}}),e.find.TAG=function(t,e){return void 0!==e.getElementsByTagName?e.getElementsByTagName(t):e.querySelectorAll(t)},e.find.CLASS=function(t,e){if(void 0!==e.getElementsByClassName&&i)return e.getElementsByClassName(t)},O=[],nt((function(t){var e;z.appendChild(t).innerHTML="",t.querySelectorAll("[selected]").length||O.push("\\["+T+"*(?:value|"+L+")"),t.querySelectorAll("[id~="+d+"-]").length||O.push("~="),t.querySelectorAll("a#"+d+"+*").length||O.push(".#.+[+~]"),t.querySelectorAll(":checked").length||O.push(":checked"),(e=r.createElement("input")).setAttribute("type","hidden"),t.appendChild(e).setAttribute("name","D"),z.appendChild(t).disabled=!0,2!==t.querySelectorAll(":disabled").length&&O.push(":enabled",":disabled"),(e=r.createElement("input")).setAttribute("name",""),t.appendChild(e),t.querySelectorAll("[name='']").length||O.push("\\["+T+"*name"+T+"*="+T+"*(?:''|\"\")")})),l.cssHas||O.push(":has"),O=O.length&&new RegExp(O.join("|")),m=function(t,e){if(t===e)return b=!0,0;var n=!t.compareDocumentPosition-!e.compareDocumentPosition;return n||(1&(n=(t.ownerDocument||t)==(e.ownerDocument||e)?t.compareDocumentPosition(e):1)||!l.sortDetached&&e.compareDocumentPosition(t)===n?t===r||t.ownerDocument==S&&Z.contains(S,t)?-1:e===r||e.ownerDocument==S&&Z.contains(S,e)?1:p?a.call(p,t)-a.call(p,e):0:4&n?-1:1)},r):r}for(t in Z.matches=function(t,e){return Z(t,null,null,e)},Z.matchesSelector=function(t,e){if(rt(t),i&&!R[e+" "]&&(!O||!O.test(e)))try{var n=A.call(t,e);if(n||l.disconnectedMatch||t.document&&11!==t.document.nodeType)return n}catch(t){R(e,!0)}return Z(e,r,null,[t]).length>0},Z.contains=function(t,e){return(t.ownerDocument||t)!=r&&rt(t),g.contains(t,e)},Z.attr=function(t,n){(t.ownerDocument||t)!=r&&rt(t);var o=e.attrHandle[n.toLowerCase()],p=o&&s.call(e.attrHandle,n.toLowerCase())?o(t,n,!i):void 0;return void 0!==p?p:t.getAttribute(n)},Z.error=function(t){throw new Error("Syntax error, unrecognized expression: "+t)},g.uniqueSort=function(t){var e,n=[],o=0,M=0;if(b=!l.sortStable,p=!l.sortStable&&c.call(t,0),N.call(t,m),b){for(;e=t[M++];)e===t[M]&&(o=n.push(M));for(;o--;)E.call(t,n[o],1)}return p=null,t},g.fn.uniqueSort=function(){return this.pushStack(g.uniqueSort(c.apply(this)))},e=g.expr={cacheLength:50,createPseudo:et,match:H,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(t){return t[1]=t[1].replace(V,K),t[3]=(t[3]||t[4]||t[5]||"").replace(V,K),"~="===t[2]&&(t[3]=" "+t[3]+" "),t.slice(0,4)},CHILD:function(t){return t[1]=t[1].toLowerCase(),"nth"===t[1].slice(0,3)?(t[3]||Z.error(t[0]),t[4]=+(t[4]?t[5]+(t[6]||1):2*("even"===t[3]||"odd"===t[3])),t[5]=+(t[7]+t[8]||"odd"===t[3])):t[3]&&Z.error(t[0]),t},PSEUDO:function(t){var e,n=!t[6]&&t[2];return H.CHILD.test(t[0])?null:(t[3]?t[2]=t[4]||t[5]||"":n&&U.test(n)&&(e=at(n,!0))&&(e=n.indexOf(")",n.length-e)-n.length)&&(t[0]=t[0].slice(0,e),t[2]=n.slice(0,e)),t.slice(0,3))}},filter:{TAG:function(t){var e=t.replace(V,K).toLowerCase();return"*"===t?function(){return!0}:function(t){return y(t,e)}},CLASS:function(t){var e=h[t+" "];return e||(e=new RegExp("(^|"+T+")"+t+"("+T+"|$)"))&&h(t,(function(t){return e.test("string"==typeof t.className&&t.className||void 0!==t.getAttribute&&t.getAttribute("class")||"")}))},ATTR:function(t,e,n){return function(o){var p=Z.attr(o,t);return null==p?"!="===e:!e||(p+="","="===e?p===n:"!="===e?p!==n:"^="===e?n&&0===p.indexOf(n):"*="===e?n&&p.indexOf(n)>-1:"$="===e?n&&p.slice(-n.length)===n:"~="===e?(" "+p.replace(k," ")+" ").indexOf(n)>-1:"|="===e&&(p===n||p.slice(0,n.length+1)===n+"-"))}},CHILD:function(t,e,n,o,p){var M="nth"!==t.slice(0,3),b="last"!==t.slice(-4),c="of-type"===e;return 1===o&&0===p?function(t){return!!t.parentNode}:function(e,n,r){var z,a,i,O,s,A=M!==b?"nextSibling":"previousSibling",u=e.parentNode,l=c&&e.nodeName.toLowerCase(),q=!r&&!c,h=!1;if(u){if(M){for(;A;){for(i=e;i=i[A];)if(c?y(i,l):1===i.nodeType)return!1;s=A="only"===t&&!s&&"nextSibling"}return!0}if(s=[b?u.firstChild:u.lastChild],b&&q){for(h=(O=(z=(a=u[d]||(u[d]={}))[t]||[])[0]===f&&z[1])&&z[2],i=O&&u.childNodes[O];i=++O&&i&&i[A]||(h=O=0)||s.pop();)if(1===i.nodeType&&++h&&i===e){a[t]=[f,O,h];break}}else if(q&&(h=O=(z=(a=e[d]||(e[d]={}))[t]||[])[0]===f&&z[1]),!1===h)for(;(i=++O&&i&&i[A]||(h=O=0)||s.pop())&&(!(c?y(i,l):1===i.nodeType)||!++h||(q&&((a=i[d]||(i[d]={}))[t]=[f,h]),i!==e)););return(h-=p)===o||h%o==0&&h/o>=0}}},PSEUDO:function(t,n){var o,p=e.pseudos[t]||e.setFilters[t.toLowerCase()]||Z.error("unsupported pseudo: "+t);return p[d]?p(n):p.length>1?(o=[t,t,"",n],e.setFilters.hasOwnProperty(t.toLowerCase())?et((function(t,e){for(var o,M=p(t,n),b=M.length;b--;)t[o=a.call(t,M[b])]=!(e[o]=M[b])})):function(t){return p(t,0,o)}):p}},pseudos:{not:et((function(t){var e=[],n=[],o=dt(t.replace(B,"$1"));return o[d]?et((function(t,e,n,p){for(var M,b=o(t,null,p,[]),c=t.length;c--;)(M=b[c])&&(t[c]=!(e[c]=M))})):function(t,p,M){return e[0]=t,o(e,null,M,n),e[0]=null,!n.pop()}})),has:et((function(t){return function(e){return Z(t,e).length>0}})),contains:et((function(t){return t=t.replace(V,K),function(e){return(e.textContent||g.text(e)).indexOf(t)>-1}})),lang:et((function(t){return j.test(t||"")||Z.error("unsupported lang: "+t),t=t.replace(V,K).toLowerCase(),function(e){var n;do{if(n=i?e.lang:e.getAttribute("xml:lang")||e.getAttribute("lang"))return(n=n.toLowerCase())===t||0===n.indexOf(t+"-")}while((e=e.parentNode)&&1===e.nodeType);return!1}})),target:function(t){var e=o.location&&o.location.hash;return e&&e.slice(1)===t.id},root:function(t){return t===z},focus:function(t){return t===function(){try{return r.activeElement}catch(t){}}()&&r.hasFocus()&&!!(t.type||t.href||~t.tabIndex)},enabled:Mt(!1),disabled:Mt(!0),checked:function(t){return y(t,"input")&&!!t.checked||y(t,"option")&&!!t.selected},selected:function(t){return t.parentNode&&t.parentNode.selectedIndex,!0===t.selected},empty:function(t){for(t=t.firstChild;t;t=t.nextSibling)if(t.nodeType<6)return!1;return!0},parent:function(t){return!e.pseudos.empty(t)},header:function(t){return G.test(t.nodeName)},input:function(t){return F.test(t.nodeName)},button:function(t){return y(t,"input")&&"button"===t.type||y(t,"button")},text:function(t){var e;return y(t,"input")&&"text"===t.type&&(null==(e=t.getAttribute("type"))||"text"===e.toLowerCase())},first:bt((function(){return[0]})),last:bt((function(t,e){return[e-1]})),eq:bt((function(t,e,n){return[n<0?n+e:n]})),even:bt((function(t,e){for(var n=0;ne?e:n;--o>=0;)t.push(o);return t})),gt:bt((function(t,e,n){for(var o=n<0?n+e:n;++o1?function(e,n,o){for(var p=t.length;p--;)if(!t[p](e,n,o))return!1;return!0}:t[0]}function At(t,e,n,o,p){for(var M,b=[],c=0,r=t.length,z=null!=e;c-1&&(M[z]=!(b[z]=O))}}else s=At(s===b?s.splice(d,s.length):s),p?p(null,b,s,r):u.apply(b,s)}))}function lt(t){for(var o,p,M,b=t.length,c=e.relative[t[0].type],r=c||e.relative[" "],z=c?1:0,i=Ot((function(t){return t===o}),r,!0),O=Ot((function(t){return a.call(o,t)>-1}),r,!0),s=[function(t,e,p){var M=!c&&(p||e!=n)||((o=e).nodeType?i(t,e,p):O(t,e,p));return o=null,M}];z1&&st(s),z>1&&it(t.slice(0,z-1).concat({value:" "===t[z-2].type?"*":""})).replace(B,"$1"),p,z0,M=t.length>0,b=function(b,c,z,a,O){var s,A,l,d=0,q="0",h=b&&[],W=[],v=n,R=b||M&&e.find.TAG("*",O),m=f+=null==v?1:Math.random()||.1,L=R.length;for(O&&(n=c==r||c||O);q!==L&&null!=(s=R[q]);q++){if(M&&s){for(A=0,c||s.ownerDocument==r||(rt(s),z=!i);l=t[A++];)if(l(s,c||r,z)){u.call(a,s);break}O&&(f=m)}p&&((s=!l&&s)&&d--,b&&h.push(s))}if(d+=q,p&&q!==d){for(A=0;l=o[A++];)l(h,W,c,z);if(b){if(d>0)for(;q--;)h[q]||W[q]||(W[q]=_.call(a));W=At(W)}u.apply(a,W),O&&!b&&W.length>0&&d+o.length>1&&g.uniqueSort(a)}return O&&(f=m,n=v),h};return p?et(b):b}(b,M)),c.selector=t}return c}function ft(t,n,o,p){var M,b,c,r,z,a="function"==typeof t&&t,O=!p&&at(t=a.selector||t);if(o=o||[],1===O.length){if((b=O[0]=O[0].slice(0)).length>2&&"ID"===(c=b[0]).type&&9===n.nodeType&&i&&e.relative[b[1].type]){if(!(n=(e.find.ID(c.matches[0].replace(V,K),n)||[])[0]))return o;a&&(n=n.parentNode),t=t.slice(b.shift().value.length)}for(M=H.needsContext.test(t)?0:b.length;M--&&(c=b[M],!e.relative[r=c.type]);)if((z=e.find[r])&&(p=z(c.matches[0].replace(V,K),$.test(b[0].type)&&ct(n.parentNode)||n))){if(b.splice(M,1),!(t=p.length&&it(b)))return u.apply(o,p),o;break}}return(a||dt(t,O))(p,n,!i,o,!n||$.test(t)&&ct(n.parentNode)||n),o}zt.prototype=e.filters=e.pseudos,e.setFilters=new zt,l.sortStable=d.split("").sort(m).join("")===d,rt(),l.sortDetached=nt((function(t){return 1&t.compareDocumentPosition(r.createElement("fieldset"))})),g.find=Z,g.expr[":"]=g.expr.pseudos,g.unique=g.uniqueSort,Z.compile=dt,Z.select=ft,Z.setDocument=rt,Z.tokenize=at,Z.escape=g.escapeSelector,Z.getText=g.text,Z.isXML=g.isXMLDoc,Z.selectors=g.expr,Z.support=g.support,Z.uniqueSort=g.uniqueSort}();var x=function(t,e,n){for(var o=[],p=void 0!==n;(t=t[e])&&9!==t.nodeType;)if(1===t.nodeType){if(p&&g(t).is(n))break;o.push(t)}return o},k=function(t,e){for(var n=[];t;t=t.nextSibling)1===t.nodeType&&t!==e&&n.push(t);return n},I=g.expr.match.needsContext,D=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function P(t,e,n){return d(e)?g.grep(t,(function(t,o){return!!e.call(t,o,t)!==n})):e.nodeType?g.grep(t,(function(t){return t===e!==n})):"string"!=typeof e?g.grep(t,(function(t){return a.call(e,t)>-1!==n})):g.filter(e,t,n)}g.filter=function(t,e,n){var o=e[0];return n&&(t=":not("+t+")"),1===e.length&&1===o.nodeType?g.find.matchesSelector(o,t)?[o]:[]:g.find.matches(t,g.grep(e,(function(t){return 1===t.nodeType})))},g.fn.extend({find:function(t){var e,n,o=this.length,p=this;if("string"!=typeof t)return this.pushStack(g(t).filter((function(){for(e=0;e1?g.uniqueSort(n):n},filter:function(t){return this.pushStack(P(this,t||[],!1))},not:function(t){return this.pushStack(P(this,t||[],!0))},is:function(t){return!!P(this,"string"==typeof t&&I.test(t)?g(t):t||[],!1).length}});var U,j=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(g.fn.init=function(t,e,n){var o,p;if(!t)return this;if(n=n||U,"string"==typeof t){if(!(o="<"===t[0]&&">"===t[t.length-1]&&t.length>=3?[null,t,null]:j.exec(t))||!o[1]&&e)return!e||e.jquery?(e||n).find(t):this.constructor(e).find(t);if(o[1]){if(e=e instanceof g?e[0]:e,g.merge(this,g.parseHTML(o[1],e&&e.nodeType?e.ownerDocument||e:q,!0)),D.test(o[1])&&g.isPlainObject(e))for(o in e)d(this[o])?this[o](e[o]):this.attr(o,e[o]);return this}return(p=q.getElementById(o[2]))&&(this[0]=p,this.length=1),this}return t.nodeType?(this[0]=t,this.length=1,this):d(t)?void 0!==n.ready?n.ready(t):t(g):g.makeArray(t,this)}).prototype=g.fn,U=g(q);var H=/^(?:parents|prev(?:Until|All))/,F={children:!0,contents:!0,next:!0,prev:!0};function G(t,e){for(;(t=t[e])&&1!==t.nodeType;);return t}g.fn.extend({has:function(t){var e=g(t,this),n=e.length;return this.filter((function(){for(var t=0;t-1:1===n.nodeType&&g.find.matchesSelector(n,t))){M.push(n);break}return this.pushStack(M.length>1?g.uniqueSort(M):M)},index:function(t){return t?"string"==typeof t?a.call(g(t),this[0]):a.call(this,t.jquery?t[0]:t):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(t,e){return this.pushStack(g.uniqueSort(g.merge(this.get(),g(t,e))))},addBack:function(t){return this.add(null==t?this.prevObject:this.prevObject.filter(t))}}),g.each({parent:function(t){var e=t.parentNode;return e&&11!==e.nodeType?e:null},parents:function(t){return x(t,"parentNode")},parentsUntil:function(t,e,n){return x(t,"parentNode",n)},next:function(t){return G(t,"nextSibling")},prev:function(t){return G(t,"previousSibling")},nextAll:function(t){return x(t,"nextSibling")},prevAll:function(t){return x(t,"previousSibling")},nextUntil:function(t,e,n){return x(t,"nextSibling",n)},prevUntil:function(t,e,n){return x(t,"previousSibling",n)},siblings:function(t){return k((t.parentNode||{}).firstChild,t)},children:function(t){return k(t.firstChild)},contents:function(t){return null!=t.contentDocument&&b(t.contentDocument)?t.contentDocument:(y(t,"template")&&(t=t.content||t),g.merge([],t.childNodes))}},(function(t,e){g.fn[t]=function(n,o){var p=g.map(this,e,n);return"Until"!==t.slice(-5)&&(o=n),o&&"string"==typeof o&&(p=g.filter(o,p)),this.length>1&&(F[t]||g.uniqueSort(p),H.test(t)&&p.reverse()),this.pushStack(p)}}));var Y=/[^\x20\t\r\n\f]+/g;function $(t){return t}function V(t){throw t}function K(t,e,n,o){var p;try{t&&d(p=t.promise)?p.call(t).done(e).fail(n):t&&d(p=t.then)?p.call(t,e,n):e.apply(void 0,[t].slice(o))}catch(t){n.apply(void 0,[t])}}g.Callbacks=function(t){t="string"==typeof t?function(t){var e={};return g.each(t.match(Y)||[],(function(t,n){e[n]=!0})),e}(t):g.extend({},t);var e,n,o,p,M=[],b=[],c=-1,r=function(){for(p=p||t.once,o=e=!0;b.length;c=-1)for(n=b.shift();++c-1;)M.splice(n,1),n<=c&&c--})),this},has:function(t){return t?g.inArray(t,M)>-1:M.length>0},empty:function(){return M&&(M=[]),this},disable:function(){return p=b=[],M=n="",this},disabled:function(){return!M},lock:function(){return p=b=[],n||e||(M=n=""),this},locked:function(){return!!p},fireWith:function(t,n){return p||(n=[t,(n=n||[]).slice?n.slice():n],b.push(n),e||r()),this},fire:function(){return z.fireWith(this,arguments),this},fired:function(){return!!o}};return z},g.extend({Deferred:function(t){var e=[["notify","progress",g.Callbacks("memory"),g.Callbacks("memory"),2],["resolve","done",g.Callbacks("once memory"),g.Callbacks("once memory"),0,"resolved"],["reject","fail",g.Callbacks("once memory"),g.Callbacks("once memory"),1,"rejected"]],n="pending",p={state:function(){return n},always:function(){return M.done(arguments).fail(arguments),this},catch:function(t){return p.then(null,t)},pipe:function(){var t=arguments;return g.Deferred((function(n){g.each(e,(function(e,o){var p=d(t[o[4]])&&t[o[4]];M[o[1]]((function(){var t=p&&p.apply(this,arguments);t&&d(t.promise)?t.promise().progress(n.notify).done(n.resolve).fail(n.reject):n[o[0]+"With"](this,p?[t]:arguments)}))})),t=null})).promise()},then:function(t,n,p){var M=0;function b(t,e,n,p){return function(){var c=this,r=arguments,z=function(){var o,z;if(!(t=M&&(n!==V&&(c=void 0,r=[o]),e.rejectWith(c,r))}};t?a():(g.Deferred.getErrorHook?a.error=g.Deferred.getErrorHook():g.Deferred.getStackHook&&(a.error=g.Deferred.getStackHook()),o.setTimeout(a))}}return g.Deferred((function(o){e[0][3].add(b(0,o,d(p)?p:$,o.notifyWith)),e[1][3].add(b(0,o,d(t)?t:$)),e[2][3].add(b(0,o,d(n)?n:V))})).promise()},promise:function(t){return null!=t?g.extend(t,p):p}},M={};return g.each(e,(function(t,o){var b=o[2],c=o[5];p[o[1]]=b.add,c&&b.add((function(){n=c}),e[3-t][2].disable,e[3-t][3].disable,e[0][2].lock,e[0][3].lock),b.add(o[3].fire),M[o[0]]=function(){return M[o[0]+"With"](this===M?void 0:this,arguments),this},M[o[0]+"With"]=b.fireWith})),p.promise(M),t&&t.call(M,M),M},when:function(t){var e=arguments.length,n=e,o=Array(n),p=c.call(arguments),M=g.Deferred(),b=function(t){return function(n){o[t]=this,p[t]=arguments.length>1?c.call(arguments):n,--e||M.resolveWith(o,p)}};if(e<=1&&(K(t,M.done(b(n)).resolve,M.reject,!e),"pending"===M.state()||d(p[n]&&p[n].then)))return M.then();for(;n--;)K(p[n],b(n),M.reject);return M.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;g.Deferred.exceptionHook=function(t,e){o.console&&o.console.warn&&t&&Q.test(t.name)&&o.console.warn("jQuery.Deferred exception: "+t.message,t.stack,e)},g.readyException=function(t){o.setTimeout((function(){throw t}))};var J=g.Deferred();function Z(){q.removeEventListener("DOMContentLoaded",Z),o.removeEventListener("load",Z),g.ready()}g.fn.ready=function(t){return J.then(t).catch((function(t){g.readyException(t)})),this},g.extend({isReady:!1,readyWait:1,ready:function(t){(!0===t?--g.readyWait:g.isReady)||(g.isReady=!0,!0!==t&&--g.readyWait>0||J.resolveWith(q,[g]))}}),g.ready.then=J.then,"complete"===q.readyState||"loading"!==q.readyState&&!q.documentElement.doScroll?o.setTimeout(g.ready):(q.addEventListener("DOMContentLoaded",Z),o.addEventListener("load",Z));var tt=function(t,e,n,o,p,M,b){var c=0,r=t.length,z=null==n;if("object"===v(n))for(c in p=!0,n)tt(t,e,c,n[c],!0,M,b);else if(void 0!==o&&(p=!0,d(o)||(b=!0),z&&(b?(e.call(t,o),e=null):(z=e,e=function(t,e,n){return z.call(g(t),n)})),e))for(;c1,null,!0)},removeData:function(t){return this.each((function(){rt.remove(this,t)}))}}),g.extend({queue:function(t,e,n){var o;if(t)return e=(e||"fx")+"queue",o=ct.get(t,e),n&&(!o||Array.isArray(n)?o=ct.access(t,e,g.makeArray(n)):o.push(n)),o||[]},dequeue:function(t,e){e=e||"fx";var n=g.queue(t,e),o=n.length,p=n.shift(),M=g._queueHooks(t,e);"inprogress"===p&&(p=n.shift(),o--),p&&("fx"===e&&n.unshift("inprogress"),delete M.stop,p.call(t,(function(){g.dequeue(t,e)}),M)),!o&&M&&M.empty.fire()},_queueHooks:function(t,e){var n=e+"queueHooks";return ct.get(t,n)||ct.access(t,n,{empty:g.Callbacks("once memory").add((function(){ct.remove(t,[e+"queue",n])}))})}}),g.fn.extend({queue:function(t,e){var n=2;return"string"!=typeof t&&(e=t,t="fx",n--),arguments.length\x20\t\r\n\f]*)/i,yt=/^$|^module$|\/(?:java|ecma)script/i;Rt=q.createDocumentFragment().appendChild(q.createElement("div")),(mt=q.createElement("input")).setAttribute("type","radio"),mt.setAttribute("checked","checked"),mt.setAttribute("name","t"),Rt.appendChild(mt),l.checkClone=Rt.cloneNode(!0).cloneNode(!0).lastChild.checked,Rt.innerHTML="",l.noCloneChecked=!!Rt.cloneNode(!0).lastChild.defaultValue,Rt.innerHTML="",l.option=!!Rt.lastChild;var _t={thead:[1,"","
      "],col:[2,"","
      "],tr:[2,"","
      "],td:[3,"","
      "],_default:[0,"",""]};function Nt(t,e){var n;return n=void 0!==t.getElementsByTagName?t.getElementsByTagName(e||"*"):void 0!==t.querySelectorAll?t.querySelectorAll(e||"*"):[],void 0===e||e&&y(t,e)?g.merge([t],n):n}function Et(t,e){for(var n=0,o=t.length;n",""]);var Tt=/<|&#?\w+;/;function Bt(t,e,n,o,p){for(var M,b,c,r,z,a,i=e.createDocumentFragment(),O=[],s=0,A=t.length;s-1)p&&p.push(M);else if(z=lt(M),b=Nt(i.appendChild(M),"script"),z&&Et(b),n)for(a=0;M=b[a++];)yt.test(M.type||"")&&n.push(M);return i}var Ct=/^([^.]*)(?:\.(.+)|)/;function wt(){return!0}function St(){return!1}function Xt(t,e,n,o,p,M){var b,c;if("object"==typeof e){for(c in"string"!=typeof n&&(o=o||n,n=void 0),e)Xt(t,c,n,o,e[c],M);return t}if(null==o&&null==p?(p=n,o=n=void 0):null==p&&("string"==typeof n?(p=o,o=void 0):(p=o,o=n,n=void 0)),!1===p)p=St;else if(!p)return t;return 1===M&&(b=p,p=function(t){return g().off(t),b.apply(this,arguments)},p.guid=b.guid||(b.guid=g.guid++)),t.each((function(){g.event.add(this,e,p,o,n)}))}function xt(t,e,n){n?(ct.set(t,e,!1),g.event.add(t,e,{namespace:!1,handler:function(t){var n,o=ct.get(this,e);if(1&t.isTrigger&&this[e]){if(o)(g.event.special[e]||{}).delegateType&&t.stopPropagation();else if(o=c.call(arguments),ct.set(this,e,o),this[e](),n=ct.get(this,e),ct.set(this,e,!1),o!==n)return t.stopImmediatePropagation(),t.preventDefault(),n}else o&&(ct.set(this,e,g.event.trigger(o[0],o.slice(1),this)),t.stopPropagation(),t.isImmediatePropagationStopped=wt)}})):void 0===ct.get(t,e)&&g.event.add(t,e,wt)}g.event={global:{},add:function(t,e,n,o,p){var M,b,c,r,z,a,i,O,s,A,u,l=ct.get(t);if(Mt(t))for(n.handler&&(n=(M=n).handler,p=M.selector),p&&g.find.matchesSelector(ut,p),n.guid||(n.guid=g.guid++),(r=l.events)||(r=l.events=Object.create(null)),(b=l.handle)||(b=l.handle=function(e){return void 0!==g&&g.event.triggered!==e.type?g.event.dispatch.apply(t,arguments):void 0}),z=(e=(e||"").match(Y)||[""]).length;z--;)s=u=(c=Ct.exec(e[z])||[])[1],A=(c[2]||"").split(".").sort(),s&&(i=g.event.special[s]||{},s=(p?i.delegateType:i.bindType)||s,i=g.event.special[s]||{},a=g.extend({type:s,origType:u,data:o,handler:n,guid:n.guid,selector:p,needsContext:p&&g.expr.match.needsContext.test(p),namespace:A.join(".")},M),(O=r[s])||((O=r[s]=[]).delegateCount=0,i.setup&&!1!==i.setup.call(t,o,A,b)||t.addEventListener&&t.addEventListener(s,b)),i.add&&(i.add.call(t,a),a.handler.guid||(a.handler.guid=n.guid)),p?O.splice(O.delegateCount++,0,a):O.push(a),g.event.global[s]=!0)},remove:function(t,e,n,o,p){var M,b,c,r,z,a,i,O,s,A,u,l=ct.hasData(t)&&ct.get(t);if(l&&(r=l.events)){for(z=(e=(e||"").match(Y)||[""]).length;z--;)if(s=u=(c=Ct.exec(e[z])||[])[1],A=(c[2]||"").split(".").sort(),s){for(i=g.event.special[s]||{},O=r[s=(o?i.delegateType:i.bindType)||s]||[],c=c[2]&&new RegExp("(^|\\.)"+A.join("\\.(?:.*\\.|)")+"(\\.|$)"),b=M=O.length;M--;)a=O[M],!p&&u!==a.origType||n&&n.guid!==a.guid||c&&!c.test(a.namespace)||o&&o!==a.selector&&("**"!==o||!a.selector)||(O.splice(M,1),a.selector&&O.delegateCount--,i.remove&&i.remove.call(t,a));b&&!O.length&&(i.teardown&&!1!==i.teardown.call(t,A,l.handle)||g.removeEvent(t,s,l.handle),delete r[s])}else for(s in r)g.event.remove(t,s+e[z],n,o,!0);g.isEmptyObject(r)&&ct.remove(t,"handle events")}},dispatch:function(t){var e,n,o,p,M,b,c=new Array(arguments.length),r=g.event.fix(t),z=(ct.get(this,"events")||Object.create(null))[r.type]||[],a=g.event.special[r.type]||{};for(c[0]=r,e=1;e=1))for(;z!==this;z=z.parentNode||this)if(1===z.nodeType&&("click"!==t.type||!0!==z.disabled)){for(M=[],b={},n=0;n-1:g.find(p,this,null,[z]).length),b[p]&&M.push(o);M.length&&c.push({elem:z,handlers:M})}return z=this,r\s*$/g;function Pt(t,e){return y(t,"table")&&y(11!==e.nodeType?e:e.firstChild,"tr")&&g(t).children("tbody")[0]||t}function Ut(t){return t.type=(null!==t.getAttribute("type"))+"/"+t.type,t}function jt(t){return"true/"===(t.type||"").slice(0,5)?t.type=t.type.slice(5):t.removeAttribute("type"),t}function Ht(t,e){var n,o,p,M,b,c;if(1===e.nodeType){if(ct.hasData(t)&&(c=ct.get(t).events))for(p in ct.remove(e,"handle events"),c)for(n=0,o=c[p].length;n1&&"string"==typeof A&&!l.checkClone&&It.test(A))return t.each((function(p){var M=t.eq(p);u&&(e[0]=A.call(this,p,M.html())),Gt(M,e,n,o)}));if(O&&(M=(p=Bt(e,t[0].ownerDocument,!1,t,o)).firstChild,1===p.childNodes.length&&(p=M),M||o)){for(c=(b=g.map(Nt(p,"script"),Ut)).length;i0&&Et(b,!r&&Nt(t,"script")),c},cleanData:function(t){for(var e,n,o,p=g.event.special,M=0;void 0!==(n=t[M]);M++)if(Mt(n)){if(e=n[ct.expando]){if(e.events)for(o in e.events)p[o]?g.event.remove(n,o):g.removeEvent(n,o,e.handle);n[ct.expando]=void 0}n[rt.expando]&&(n[rt.expando]=void 0)}}}),g.fn.extend({detach:function(t){return Yt(this,t,!0)},remove:function(t){return Yt(this,t)},text:function(t){return tt(this,(function(t){return void 0===t?g.text(this):this.empty().each((function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=t)}))}),null,t,arguments.length)},append:function(){return Gt(this,arguments,(function(t){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Pt(this,t).appendChild(t)}))},prepend:function(){return Gt(this,arguments,(function(t){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var e=Pt(this,t);e.insertBefore(t,e.firstChild)}}))},before:function(){return Gt(this,arguments,(function(t){this.parentNode&&this.parentNode.insertBefore(t,this)}))},after:function(){return Gt(this,arguments,(function(t){this.parentNode&&this.parentNode.insertBefore(t,this.nextSibling)}))},empty:function(){for(var t,e=0;null!=(t=this[e]);e++)1===t.nodeType&&(g.cleanData(Nt(t,!1)),t.textContent="");return this},clone:function(t,e){return t=null!=t&&t,e=null==e?t:e,this.map((function(){return g.clone(this,t,e)}))},html:function(t){return tt(this,(function(t){var e=this[0]||{},n=0,o=this.length;if(void 0===t&&1===e.nodeType)return e.innerHTML;if("string"==typeof t&&!kt.test(t)&&!_t[(Lt.exec(t)||["",""])[1].toLowerCase()]){t=g.htmlPrefilter(t);try{for(;n=0&&(r+=Math.max(0,Math.ceil(t["offset"+e[0].toUpperCase()+e.slice(1)]-M-r-c-.5))||0),r+z}function ae(t,e,n){var o=Kt(t),p=(!l.boxSizingReliable()||n)&&"border-box"===g.css(t,"boxSizing",!1,o),M=p,b=Zt(t,e,o),c="offset"+e[0].toUpperCase()+e.slice(1);if($t.test(b)){if(!n)return b;b="auto"}return(!l.boxSizingReliable()&&p||!l.reliableTrDimensions()&&y(t,"tr")||"auto"===b||!parseFloat(b)&&"inline"===g.css(t,"display",!1,o))&&t.getClientRects().length&&(p="border-box"===g.css(t,"boxSizing",!1,o),(M=c in t)&&(b=t[c])),(b=parseFloat(b)||0)+ze(t,e,n||(p?"border":"content"),M,o,b)+"px"}function ie(t,e,n,o,p){return new ie.prototype.init(t,e,n,o,p)}g.extend({cssHooks:{opacity:{get:function(t,e){if(e){var n=Zt(t,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,aspectRatio:!0,borderImageSlice:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,scale:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeMiterlimit:!0,strokeOpacity:!0},cssProps:{},style:function(t,e,n,o){if(t&&3!==t.nodeType&&8!==t.nodeType&&t.style){var p,M,b,c=pt(e),r=Vt.test(e),z=t.style;if(r||(e=pe(c)),b=g.cssHooks[e]||g.cssHooks[c],void 0===n)return b&&"get"in b&&void 0!==(p=b.get(t,!1,o))?p:z[e];"string"===(M=typeof n)&&(p=st.exec(n))&&p[1]&&(n=qt(t,e,p),M="number"),null!=n&&n==n&&("number"!==M||r||(n+=p&&p[3]||(g.cssNumber[c]?"":"px")),l.clearCloneStyle||""!==n||0!==e.indexOf("background")||(z[e]="inherit"),b&&"set"in b&&void 0===(n=b.set(t,n,o))||(r?z.setProperty(e,n):z[e]=n))}},css:function(t,e,n,o){var p,M,b,c=pt(e);return Vt.test(e)||(e=pe(c)),(b=g.cssHooks[e]||g.cssHooks[c])&&"get"in b&&(p=b.get(t,!0,n)),void 0===p&&(p=Zt(t,e,o)),"normal"===p&&e in ce&&(p=ce[e]),""===n||n?(M=parseFloat(p),!0===n||isFinite(M)?M||0:p):p}}),g.each(["height","width"],(function(t,e){g.cssHooks[e]={get:function(t,n,o){if(n)return!Me.test(g.css(t,"display"))||t.getClientRects().length&&t.getBoundingClientRect().width?ae(t,e,o):Qt(t,be,(function(){return ae(t,e,o)}))},set:function(t,n,o){var p,M=Kt(t),b=!l.scrollboxSize()&&"absolute"===M.position,c=(b||o)&&"border-box"===g.css(t,"boxSizing",!1,M),r=o?ze(t,e,o,c,M):0;return c&&b&&(r-=Math.ceil(t["offset"+e[0].toUpperCase()+e.slice(1)]-parseFloat(M[e])-ze(t,e,"border",!1,M)-.5)),r&&(p=st.exec(n))&&"px"!==(p[3]||"px")&&(t.style[e]=n,n=g.css(t,e)),re(0,n,r)}}})),g.cssHooks.marginLeft=te(l.reliableMarginLeft,(function(t,e){if(e)return(parseFloat(Zt(t,"marginLeft"))||t.getBoundingClientRect().left-Qt(t,{marginLeft:0},(function(){return t.getBoundingClientRect().left})))+"px"})),g.each({margin:"",padding:"",border:"Width"},(function(t,e){g.cssHooks[t+e]={expand:function(n){for(var o=0,p={},M="string"==typeof n?n.split(" "):[n];o<4;o++)p[t+At[o]+e]=M[o]||M[o-2]||M[0];return p}},"margin"!==t&&(g.cssHooks[t+e].set=re)})),g.fn.extend({css:function(t,e){return tt(this,(function(t,e,n){var o,p,M={},b=0;if(Array.isArray(e)){for(o=Kt(t),p=e.length;b1)}}),g.Tween=ie,ie.prototype={constructor:ie,init:function(t,e,n,o,p,M){this.elem=t,this.prop=n,this.easing=p||g.easing._default,this.options=e,this.start=this.now=this.cur(),this.end=o,this.unit=M||(g.cssNumber[n]?"":"px")},cur:function(){var t=ie.propHooks[this.prop];return t&&t.get?t.get(this):ie.propHooks._default.get(this)},run:function(t){var e,n=ie.propHooks[this.prop];return this.options.duration?this.pos=e=g.easing[this.easing](t,this.options.duration*t,0,1,this.options.duration):this.pos=e=t,this.now=(this.end-this.start)*e+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):ie.propHooks._default.set(this),this}},ie.prototype.init.prototype=ie.prototype,ie.propHooks={_default:{get:function(t){var e;return 1!==t.elem.nodeType||null!=t.elem[t.prop]&&null==t.elem.style[t.prop]?t.elem[t.prop]:(e=g.css(t.elem,t.prop,""))&&"auto"!==e?e:0},set:function(t){g.fx.step[t.prop]?g.fx.step[t.prop](t):1!==t.elem.nodeType||!g.cssHooks[t.prop]&&null==t.elem.style[pe(t.prop)]?t.elem[t.prop]=t.now:g.style(t.elem,t.prop,t.now+t.unit)}}},ie.propHooks.scrollTop=ie.propHooks.scrollLeft={set:function(t){t.elem.nodeType&&t.elem.parentNode&&(t.elem[t.prop]=t.now)}},g.easing={linear:function(t){return t},swing:function(t){return.5-Math.cos(t*Math.PI)/2},_default:"swing"},g.fx=ie.prototype.init,g.fx.step={};var Oe,se,Ae=/^(?:toggle|show|hide)$/,ue=/queueHooks$/;function le(){se&&(!1===q.hidden&&o.requestAnimationFrame?o.requestAnimationFrame(le):o.setTimeout(le,g.fx.interval),g.fx.tick())}function de(){return o.setTimeout((function(){Oe=void 0})),Oe=Date.now()}function fe(t,e){var n,o=0,p={height:t};for(e=e?1:0;o<4;o+=2-e)p["margin"+(n=At[o])]=p["padding"+n]=t;return e&&(p.opacity=p.width=t),p}function qe(t,e,n){for(var o,p=(he.tweeners[e]||[]).concat(he.tweeners["*"]),M=0,b=p.length;M1)},removeAttr:function(t){return this.each((function(){g.removeAttr(this,t)}))}}),g.extend({attr:function(t,e,n){var o,p,M=t.nodeType;if(3!==M&&8!==M&&2!==M)return void 0===t.getAttribute?g.prop(t,e,n):(1===M&&g.isXMLDoc(t)||(p=g.attrHooks[e.toLowerCase()]||(g.expr.match.bool.test(e)?We:void 0)),void 0!==n?null===n?void g.removeAttr(t,e):p&&"set"in p&&void 0!==(o=p.set(t,n,e))?o:(t.setAttribute(e,n+""),n):p&&"get"in p&&null!==(o=p.get(t,e))?o:null==(o=g.find.attr(t,e))?void 0:o)},attrHooks:{type:{set:function(t,e){if(!l.radioValue&&"radio"===e&&y(t,"input")){var n=t.value;return t.setAttribute("type",e),n&&(t.value=n),e}}}},removeAttr:function(t,e){var n,o=0,p=e&&e.match(Y);if(p&&1===t.nodeType)for(;n=p[o++];)t.removeAttribute(n)}}),We={set:function(t,e,n){return!1===e?g.removeAttr(t,n):t.setAttribute(n,n),n}},g.each(g.expr.match.bool.source.match(/\w+/g),(function(t,e){var n=ve[e]||g.find.attr;ve[e]=function(t,e,o){var p,M,b=e.toLowerCase();return o||(M=ve[b],ve[b]=p,p=null!=n(t,e,o)?b:null,ve[b]=M),p}}));var Re=/^(?:input|select|textarea|button)$/i,me=/^(?:a|area)$/i;function ge(t){return(t.match(Y)||[]).join(" ")}function Le(t){return t.getAttribute&&t.getAttribute("class")||""}function ye(t){return Array.isArray(t)?t:"string"==typeof t&&t.match(Y)||[]}g.fn.extend({prop:function(t,e){return tt(this,g.prop,t,e,arguments.length>1)},removeProp:function(t){return this.each((function(){delete this[g.propFix[t]||t]}))}}),g.extend({prop:function(t,e,n){var o,p,M=t.nodeType;if(3!==M&&8!==M&&2!==M)return 1===M&&g.isXMLDoc(t)||(e=g.propFix[e]||e,p=g.propHooks[e]),void 0!==n?p&&"set"in p&&void 0!==(o=p.set(t,n,e))?o:t[e]=n:p&&"get"in p&&null!==(o=p.get(t,e))?o:t[e]},propHooks:{tabIndex:{get:function(t){var e=g.find.attr(t,"tabindex");return e?parseInt(e,10):Re.test(t.nodeName)||me.test(t.nodeName)&&t.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),l.optSelected||(g.propHooks.selected={get:function(t){var e=t.parentNode;return e&&e.parentNode&&e.parentNode.selectedIndex,null},set:function(t){var e=t.parentNode;e&&(e.selectedIndex,e.parentNode&&e.parentNode.selectedIndex)}}),g.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],(function(){g.propFix[this.toLowerCase()]=this})),g.fn.extend({addClass:function(t){var e,n,o,p,M,b;return d(t)?this.each((function(e){g(this).addClass(t.call(this,e,Le(this)))})):(e=ye(t)).length?this.each((function(){if(o=Le(this),n=1===this.nodeType&&" "+ge(o)+" "){for(M=0;M-1;)n=n.replace(" "+p+" "," ");b=ge(n),o!==b&&this.setAttribute("class",b)}})):this:this.attr("class","")},toggleClass:function(t,e){var n,o,p,M,b=typeof t,c="string"===b||Array.isArray(t);return d(t)?this.each((function(n){g(this).toggleClass(t.call(this,n,Le(this),e),e)})):"boolean"==typeof e&&c?e?this.addClass(t):this.removeClass(t):(n=ye(t),this.each((function(){if(c)for(M=g(this),p=0;p-1)return!0;return!1}});var _e=/\r/g;g.fn.extend({val:function(t){var e,n,o,p=this[0];return arguments.length?(o=d(t),this.each((function(n){var p;1===this.nodeType&&(null==(p=o?t.call(this,n,g(this).val()):t)?p="":"number"==typeof p?p+="":Array.isArray(p)&&(p=g.map(p,(function(t){return null==t?"":t+""}))),(e=g.valHooks[this.type]||g.valHooks[this.nodeName.toLowerCase()])&&"set"in e&&void 0!==e.set(this,p,"value")||(this.value=p))}))):p?(e=g.valHooks[p.type]||g.valHooks[p.nodeName.toLowerCase()])&&"get"in e&&void 0!==(n=e.get(p,"value"))?n:"string"==typeof(n=p.value)?n.replace(_e,""):null==n?"":n:void 0}}),g.extend({valHooks:{option:{get:function(t){var e=g.find.attr(t,"value");return null!=e?e:ge(g.text(t))}},select:{get:function(t){var e,n,o,p=t.options,M=t.selectedIndex,b="select-one"===t.type,c=b?null:[],r=b?M+1:p.length;for(o=M<0?r:b?M:0;o-1)&&(n=!0);return n||(t.selectedIndex=-1),M}}}}),g.each(["radio","checkbox"],(function(){g.valHooks[this]={set:function(t,e){if(Array.isArray(e))return t.checked=g.inArray(g(t).val(),e)>-1}},l.checkOn||(g.valHooks[this].get=function(t){return null===t.getAttribute("value")?"on":t.value})}));var Ne=o.location,Ee={guid:Date.now()},Te=/\?/;g.parseXML=function(t){var e,n;if(!t||"string"!=typeof t)return null;try{e=(new o.DOMParser).parseFromString(t,"text/xml")}catch(t){}return n=e&&e.getElementsByTagName("parsererror")[0],e&&!n||g.error("Invalid XML: "+(n?g.map(n.childNodes,(function(t){return t.textContent})).join("\n"):t)),e};var Be=/^(?:focusinfocus|focusoutblur)$/,Ce=function(t){t.stopPropagation()};g.extend(g.event,{trigger:function(t,e,n,p){var M,b,c,r,z,a,i,O,A=[n||q],u=s.call(t,"type")?t.type:t,l=s.call(t,"namespace")?t.namespace.split("."):[];if(b=O=c=n=n||q,3!==n.nodeType&&8!==n.nodeType&&!Be.test(u+g.event.triggered)&&(u.indexOf(".")>-1&&(l=u.split("."),u=l.shift(),l.sort()),z=u.indexOf(":")<0&&"on"+u,(t=t[g.expando]?t:new g.Event(u,"object"==typeof t&&t)).isTrigger=p?2:3,t.namespace=l.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+l.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=n),e=null==e?[t]:g.makeArray(e,[t]),i=g.event.special[u]||{},p||!i.trigger||!1!==i.trigger.apply(n,e))){if(!p&&!i.noBubble&&!f(n)){for(r=i.delegateType||u,Be.test(r+u)||(b=b.parentNode);b;b=b.parentNode)A.push(b),c=b;c===(n.ownerDocument||q)&&A.push(c.defaultView||c.parentWindow||o)}for(M=0;(b=A[M++])&&!t.isPropagationStopped();)O=b,t.type=M>1?r:i.bindType||u,(a=(ct.get(b,"events")||Object.create(null))[t.type]&&ct.get(b,"handle"))&&a.apply(b,e),(a=z&&b[z])&&a.apply&&Mt(b)&&(t.result=a.apply(b,e),!1===t.result&&t.preventDefault());return t.type=u,p||t.isDefaultPrevented()||i._default&&!1!==i._default.apply(A.pop(),e)||!Mt(n)||z&&d(n[u])&&!f(n)&&((c=n[z])&&(n[z]=null),g.event.triggered=u,t.isPropagationStopped()&&O.addEventListener(u,Ce),n[u](),t.isPropagationStopped()&&O.removeEventListener(u,Ce),g.event.triggered=void 0,c&&(n[z]=c)),t.result}},simulate:function(t,e,n){var o=g.extend(new g.Event,n,{type:t,isSimulated:!0});g.event.trigger(o,null,e)}}),g.fn.extend({trigger:function(t,e){return this.each((function(){g.event.trigger(t,e,this)}))},triggerHandler:function(t,e){var n=this[0];if(n)return g.event.trigger(t,e,n,!0)}});var we=/\[\]$/,Se=/\r?\n/g,Xe=/^(?:submit|button|image|reset|file)$/i,xe=/^(?:input|select|textarea|keygen)/i;function ke(t,e,n,o){var p;if(Array.isArray(e))g.each(e,(function(e,p){n||we.test(t)?o(t,p):ke(t+"["+("object"==typeof p&&null!=p?e:"")+"]",p,n,o)}));else if(n||"object"!==v(e))o(t,e);else for(p in e)ke(t+"["+p+"]",e[p],n,o)}g.param=function(t,e){var n,o=[],p=function(t,e){var n=d(e)?e():e;o[o.length]=encodeURIComponent(t)+"="+encodeURIComponent(null==n?"":n)};if(null==t)return"";if(Array.isArray(t)||t.jquery&&!g.isPlainObject(t))g.each(t,(function(){p(this.name,this.value)}));else for(n in t)ke(n,t[n],e,p);return o.join("&")},g.fn.extend({serialize:function(){return g.param(this.serializeArray())},serializeArray:function(){return this.map((function(){var t=g.prop(this,"elements");return t?g.makeArray(t):this})).filter((function(){var t=this.type;return this.name&&!g(this).is(":disabled")&&xe.test(this.nodeName)&&!Xe.test(t)&&(this.checked||!gt.test(t))})).map((function(t,e){var n=g(this).val();return null==n?null:Array.isArray(n)?g.map(n,(function(t){return{name:e.name,value:t.replace(Se,"\r\n")}})):{name:e.name,value:n.replace(Se,"\r\n")}})).get()}});var Ie=/%20/g,De=/#.*$/,Pe=/([?&])_=[^&]*/,Ue=/^(.*?):[ \t]*([^\r\n]*)$/gm,je=/^(?:GET|HEAD)$/,He=/^\/\//,Fe={},Ge={},Ye="*/".concat("*"),$e=q.createElement("a");function Ve(t){return function(e,n){"string"!=typeof e&&(n=e,e="*");var o,p=0,M=e.toLowerCase().match(Y)||[];if(d(n))for(;o=M[p++];)"+"===o[0]?(o=o.slice(1)||"*",(t[o]=t[o]||[]).unshift(n)):(t[o]=t[o]||[]).push(n)}}function Ke(t,e,n,o){var p={},M=t===Ge;function b(c){var r;return p[c]=!0,g.each(t[c]||[],(function(t,c){var z=c(e,n,o);return"string"!=typeof z||M||p[z]?M?!(r=z):void 0:(e.dataTypes.unshift(z),b(z),!1)})),r}return b(e.dataTypes[0])||!p["*"]&&b("*")}function Qe(t,e){var n,o,p=g.ajaxSettings.flatOptions||{};for(n in e)void 0!==e[n]&&((p[n]?t:o||(o={}))[n]=e[n]);return o&&g.extend(!0,t,o),t}$e.href=Ne.href,g.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Ne.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(Ne.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Ye,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":g.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(t,e){return e?Qe(Qe(t,g.ajaxSettings),e):Qe(g.ajaxSettings,t)},ajaxPrefilter:Ve(Fe),ajaxTransport:Ve(Ge),ajax:function(t,e){"object"==typeof t&&(e=t,t=void 0),e=e||{};var n,p,M,b,c,r,z,a,i,O,s=g.ajaxSetup({},e),A=s.context||s,u=s.context&&(A.nodeType||A.jquery)?g(A):g.event,l=g.Deferred(),d=g.Callbacks("once memory"),f=s.statusCode||{},h={},W={},v="canceled",R={readyState:0,getResponseHeader:function(t){var e;if(z){if(!b)for(b={};e=Ue.exec(M);)b[e[1].toLowerCase()+" "]=(b[e[1].toLowerCase()+" "]||[]).concat(e[2]);e=b[t.toLowerCase()+" "]}return null==e?null:e.join(", ")},getAllResponseHeaders:function(){return z?M:null},setRequestHeader:function(t,e){return null==z&&(t=W[t.toLowerCase()]=W[t.toLowerCase()]||t,h[t]=e),this},overrideMimeType:function(t){return null==z&&(s.mimeType=t),this},statusCode:function(t){var e;if(t)if(z)R.always(t[R.status]);else for(e in t)f[e]=[f[e],t[e]];return this},abort:function(t){var e=t||v;return n&&n.abort(e),m(0,e),this}};if(l.promise(R),s.url=((t||s.url||Ne.href)+"").replace(He,Ne.protocol+"//"),s.type=e.method||e.type||s.method||s.type,s.dataTypes=(s.dataType||"*").toLowerCase().match(Y)||[""],null==s.crossDomain){r=q.createElement("a");try{r.href=s.url,r.href=r.href,s.crossDomain=$e.protocol+"//"+$e.host!=r.protocol+"//"+r.host}catch(t){s.crossDomain=!0}}if(s.data&&s.processData&&"string"!=typeof s.data&&(s.data=g.param(s.data,s.traditional)),Ke(Fe,s,e,R),z)return R;for(i in(a=g.event&&s.global)&&0==g.active++&&g.event.trigger("ajaxStart"),s.type=s.type.toUpperCase(),s.hasContent=!je.test(s.type),p=s.url.replace(De,""),s.hasContent?s.data&&s.processData&&0===(s.contentType||"").indexOf("application/x-www-form-urlencoded")&&(s.data=s.data.replace(Ie,"+")):(O=s.url.slice(p.length),s.data&&(s.processData||"string"==typeof s.data)&&(p+=(Te.test(p)?"&":"?")+s.data,delete s.data),!1===s.cache&&(p=p.replace(Pe,"$1"),O=(Te.test(p)?"&":"?")+"_="+Ee.guid+++O),s.url=p+O),s.ifModified&&(g.lastModified[p]&&R.setRequestHeader("If-Modified-Since",g.lastModified[p]),g.etag[p]&&R.setRequestHeader("If-None-Match",g.etag[p])),(s.data&&s.hasContent&&!1!==s.contentType||e.contentType)&&R.setRequestHeader("Content-Type",s.contentType),R.setRequestHeader("Accept",s.dataTypes[0]&&s.accepts[s.dataTypes[0]]?s.accepts[s.dataTypes[0]]+("*"!==s.dataTypes[0]?", "+Ye+"; q=0.01":""):s.accepts["*"]),s.headers)R.setRequestHeader(i,s.headers[i]);if(s.beforeSend&&(!1===s.beforeSend.call(A,R,s)||z))return R.abort();if(v="abort",d.add(s.complete),R.done(s.success),R.fail(s.error),n=Ke(Ge,s,e,R)){if(R.readyState=1,a&&u.trigger("ajaxSend",[R,s]),z)return R;s.async&&s.timeout>0&&(c=o.setTimeout((function(){R.abort("timeout")}),s.timeout));try{z=!1,n.send(h,m)}catch(t){if(z)throw t;m(-1,t)}}else m(-1,"No Transport");function m(t,e,b,r){var i,O,q,h,W,v=e;z||(z=!0,c&&o.clearTimeout(c),n=void 0,M=r||"",R.readyState=t>0?4:0,i=t>=200&&t<300||304===t,b&&(h=function(t,e,n){for(var o,p,M,b,c=t.contents,r=t.dataTypes;"*"===r[0];)r.shift(),void 0===o&&(o=t.mimeType||e.getResponseHeader("Content-Type"));if(o)for(p in c)if(c[p]&&c[p].test(o)){r.unshift(p);break}if(r[0]in n)M=r[0];else{for(p in n){if(!r[0]||t.converters[p+" "+r[0]]){M=p;break}b||(b=p)}M=M||b}if(M)return M!==r[0]&&r.unshift(M),n[M]}(s,R,b)),!i&&g.inArray("script",s.dataTypes)>-1&&g.inArray("json",s.dataTypes)<0&&(s.converters["text script"]=function(){}),h=function(t,e,n,o){var p,M,b,c,r,z={},a=t.dataTypes.slice();if(a[1])for(b in t.converters)z[b.toLowerCase()]=t.converters[b];for(M=a.shift();M;)if(t.responseFields[M]&&(n[t.responseFields[M]]=e),!r&&o&&t.dataFilter&&(e=t.dataFilter(e,t.dataType)),r=M,M=a.shift())if("*"===M)M=r;else if("*"!==r&&r!==M){if(!(b=z[r+" "+M]||z["* "+M]))for(p in z)if((c=p.split(" "))[1]===M&&(b=z[r+" "+c[0]]||z["* "+c[0]])){!0===b?b=z[p]:!0!==z[p]&&(M=c[0],a.unshift(c[1]));break}if(!0!==b)if(b&&t.throws)e=b(e);else try{e=b(e)}catch(t){return{state:"parsererror",error:b?t:"No conversion from "+r+" to "+M}}}return{state:"success",data:e}}(s,h,R,i),i?(s.ifModified&&((W=R.getResponseHeader("Last-Modified"))&&(g.lastModified[p]=W),(W=R.getResponseHeader("etag"))&&(g.etag[p]=W)),204===t||"HEAD"===s.type?v="nocontent":304===t?v="notmodified":(v=h.state,O=h.data,i=!(q=h.error))):(q=v,!t&&v||(v="error",t<0&&(t=0))),R.status=t,R.statusText=(e||v)+"",i?l.resolveWith(A,[O,v,R]):l.rejectWith(A,[R,v,q]),R.statusCode(f),f=void 0,a&&u.trigger(i?"ajaxSuccess":"ajaxError",[R,s,i?O:q]),d.fireWith(A,[R,v]),a&&(u.trigger("ajaxComplete",[R,s]),--g.active||g.event.trigger("ajaxStop")))}return R},getJSON:function(t,e,n){return g.get(t,e,n,"json")},getScript:function(t,e){return g.get(t,void 0,e,"script")}}),g.each(["get","post"],(function(t,e){g[e]=function(t,n,o,p){return d(n)&&(p=p||o,o=n,n=void 0),g.ajax(g.extend({url:t,type:e,dataType:p,data:n,success:o},g.isPlainObject(t)&&t))}})),g.ajaxPrefilter((function(t){var e;for(e in t.headers)"content-type"===e.toLowerCase()&&(t.contentType=t.headers[e]||"")})),g._evalUrl=function(t,e,n){return g.ajax({url:t,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(t){g.globalEval(t,e,n)}})},g.fn.extend({wrapAll:function(t){var e;return this[0]&&(d(t)&&(t=t.call(this[0])),e=g(t,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&e.insertBefore(this[0]),e.map((function(){for(var t=this;t.firstElementChild;)t=t.firstElementChild;return t})).append(this)),this},wrapInner:function(t){return d(t)?this.each((function(e){g(this).wrapInner(t.call(this,e))})):this.each((function(){var e=g(this),n=e.contents();n.length?n.wrapAll(t):e.append(t)}))},wrap:function(t){var e=d(t);return this.each((function(n){g(this).wrapAll(e?t.call(this,n):t)}))},unwrap:function(t){return this.parent(t).not("body").each((function(){g(this).replaceWith(this.childNodes)})),this}}),g.expr.pseudos.hidden=function(t){return!g.expr.pseudos.visible(t)},g.expr.pseudos.visible=function(t){return!!(t.offsetWidth||t.offsetHeight||t.getClientRects().length)},g.ajaxSettings.xhr=function(){try{return new o.XMLHttpRequest}catch(t){}};var Je={0:200,1223:204},Ze=g.ajaxSettings.xhr();l.cors=!!Ze&&"withCredentials"in Ze,l.ajax=Ze=!!Ze,g.ajaxTransport((function(t){var e,n;if(l.cors||Ze&&!t.crossDomain)return{send:function(p,M){var b,c=t.xhr();if(c.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(b in t.xhrFields)c[b]=t.xhrFields[b];for(b in t.mimeType&&c.overrideMimeType&&c.overrideMimeType(t.mimeType),t.crossDomain||p["X-Requested-With"]||(p["X-Requested-With"]="XMLHttpRequest"),p)c.setRequestHeader(b,p[b]);e=function(t){return function(){e&&(e=n=c.onload=c.onerror=c.onabort=c.ontimeout=c.onreadystatechange=null,"abort"===t?c.abort():"error"===t?"number"!=typeof c.status?M(0,"error"):M(c.status,c.statusText):M(Je[c.status]||c.status,c.statusText,"text"!==(c.responseType||"text")||"string"!=typeof c.responseText?{binary:c.response}:{text:c.responseText},c.getAllResponseHeaders()))}},c.onload=e(),n=c.onerror=c.ontimeout=e("error"),void 0!==c.onabort?c.onabort=n:c.onreadystatechange=function(){4===c.readyState&&o.setTimeout((function(){e&&n()}))},e=e("abort");try{c.send(t.hasContent&&t.data||null)}catch(t){if(e)throw t}},abort:function(){e&&e()}}})),g.ajaxPrefilter((function(t){t.crossDomain&&(t.contents.script=!1)})),g.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(t){return g.globalEval(t),t}}}),g.ajaxPrefilter("script",(function(t){void 0===t.cache&&(t.cache=!1),t.crossDomain&&(t.type="GET")})),g.ajaxTransport("script",(function(t){var e,n;if(t.crossDomain||t.scriptAttrs)return{send:function(o,p){e=g(" --}} + }, + + getSelectedText() { + if (!this.selected || this.selected === 'default') return ''; + const option = this.options.find(opt => opt.value == this.selected); + return option ? option.text : this.selected; + }, + + isDefaultValue() { + return !this.selected || this.selected === 'default' || this.selected === ''; + } +}" @click.outside="open = false" class="relative"> + + {{-- Hidden input for form validation --}} + + + {{-- Input Container --}} +
      + + {{-- Display Selected Value or Search Input --}} +
      + + +
      + + {{-- Dropdown Arrow --}} + +
      + + {{-- Dropdown Options --}} +
      + + + + +
      + + {{-- Hidden datalist for options --}} + + {{ $slot }} + +

+@endif + +@error($modelBinding) + +@enderror diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 20e6485bf..6b88c7b44 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -27,30 +27,30 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov @endif merge(['class' => $defaultClass]) }} @required($required) - @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" - type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + wire:loading.attr="disabled" + type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" - aria-placeholder="{{ $attributes->get('placeholder') }}"> + aria-placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @else merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) - @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}" max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" maxlength="{{ $attributes->get('maxlength') }}" - @if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}" - placeholder="{{ $attributes->get('placeholder') }}"> + @if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}" + placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @endif @if (!$label && $helper) @endif - @error($id) + @error($modelBinding) diff --git a/resources/views/components/forms/monaco-editor.blade.php b/resources/views/components/forms/monaco-editor.blade.php index 811953153..36da2ebcb 100644 --- a/resources/views/components/forms/monaco-editor.blade.php +++ b/resources/views/components/forms/monaco-editor.blade.php @@ -81,8 +81,13 @@ document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => { editor.focus(); }); - + updatePlaceholder(editor.getValue()); + + @if ($autofocus) + // Auto-focus the editor + setTimeout(() => editor.focus(), 100); + @endif $watch('monacoContent', value => { if (editor.getValue() !== value) { @@ -99,7 +104,7 @@ }, 5);" :id="monacoId">
-
+
whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif> + wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}" + @if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @else wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif> {{ $slot }} - @error($id) + @error($modelBinding) diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index b4dec192a..cee5faeda 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -25,9 +25,9 @@ function handleKeydown(e) { @endif @if ($useMonacoEditor) - + @else @if ($type === 'password')
@@ -45,35 +45,34 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @endif merge(['class' => $defaultClassInput]) }} @required($required) - @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" - type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + wire:loading.attr="disabled" + type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}"> + wire:model={{ $value ?? $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + @disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}" + name="{{ $name }}" name={{ $modelBinding }} + @if ($autofocus) x-ref="autofocusInput" @endif>
@else + wire:model={{ $value ?? $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif + @disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}" + name="{{ $name }}" name={{ $modelBinding }} + @if ($autofocus) x-ref="autofocusInput" @endif> @endif @endif - @error($id) + @error($modelBinding) diff --git a/resources/views/components/loading-on-button.blade.php b/resources/views/components/loading-on-button.blade.php index 78a1f9726..a451023aa 100644 --- a/resources/views/components/loading-on-button.blade.php +++ b/resources/views/components/loading-on-button.blade.php @@ -3,7 +3,7 @@ @if (isset($text))
{{ $text }}
@endif - null, 'checkboxes' => [], 'actions' => [], + 'warningMessage' => null, 'confirmWithText' => true, 'confirmationText' => 'Confirm Deletion', 'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below', @@ -23,11 +24,15 @@ 'dispatchEventType' => 'success', 'dispatchEventMessage' => '', 'ignoreWire' => true, + 'temporaryDisableTwoStepConfirmation' => false, ]) @php use App\Models\InstanceSettings; $disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation'); + if ($temporaryDisableTwoStepConfirmation) { + $disableTwoStepConfirmation = false; + } @endphp
@if ($customButton) @if ($buttonFullWidth) @@ -192,9 +201,6 @@ class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-f @if (!empty($checkboxes))
-
-

Actions

-
@foreach ($checkboxes as $index => $checkbox)
- + + {!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!} +
The following actions will be performed:
    @foreach ($actions as $action) @@ -246,6 +250,18 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> {{ $checkbox['label'] }} + @if (isset($checkbox['default_warning'])) + + @endif @endforeach
@if (!$disableTwoStepConfirmation) @@ -253,8 +269,21 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">

Confirm Actions

{{ $confirmationLabel }}

-
- +
+
+ + +
+ class="p-2 mt-1 px-3 w-full rounded-sm input">
@endif @endif @@ -303,10 +332,9 @@ class="w-auto" isError @if (!$disableTwoStepConfirmation)
- + + Please enter your password to confirm this destructive action. +
@php $passwordConfirm = Str::uuid(); diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 71959d66d..6291e8774 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,8 +8,11 @@ 'content' => null, 'closeOutside' => true, 'minWidth' => '36rem', + 'isFullWidth' => false, ]) -
@if ($content)
@@ -17,17 +20,18 @@ class="relative w-auto h-auto" wire:ignore>
@else @if ($disabled) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isErrorButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isHighlightedButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @else - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @endif @endif