diff --git a/.github/workflows/cleanup-ghcr-untagged.yml b/.github/workflows/cleanup-ghcr-untagged.yml new file mode 100644 index 000000000..394fba68f --- /dev/null +++ b/.github/workflows/cleanup-ghcr-untagged.yml @@ -0,0 +1,25 @@ +name: Cleanup Untagged GHCR Images + +on: + workflow_dispatch: # Manual trigger only + +env: + GITHUB_REGISTRY: ghcr.io + +jobs: + cleanup-all-packages: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host'] + steps: + - name: Delete untagged ${{ matrix.package }} images + uses: actions/delete-package-versions@v5 + with: + package-name: ${{ matrix.package }} + package-type: 'container' + min-versions-to-keep: 0 + delete-only-untagged-versions: 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index a1dc33b5c..0d28581a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,77 @@ ## [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 @@ -205,6 +276,7 @@ ### ๐Ÿ“š Documentation - *(database-patterns)* Add critical note on mass assignment protection for new columns - Clarify cloud-init script compatibility - Update changelog +- Update changelog ### ๐ŸŽจ Styling diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php index 859aec6f6..71b6ed52b 100644 --- a/app/Actions/Stripe/CancelSubscription.php +++ b/app/Actions/Stripe/CancelSubscription.php @@ -30,7 +30,7 @@ public function getSubscriptionsPreview(): Collection $subscriptions = collect(); // Get all teams the user belongs to - $teams = $this->user->teams; + $teams = $this->user->teams()->get(); foreach ($teams as $team) { // Only include subscriptions from teams where user is owner @@ -49,6 +49,64 @@ public function getSubscriptionsPreview(): Collection 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) { diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php index 7b2e7318d..3c539d7c5 100644 --- a/app/Actions/User/DeleteUserResources.php +++ b/app/Actions/User/DeleteUserResources.php @@ -24,23 +24,46 @@ public function getResourcesPreview(): array $services = collect(); // Get all teams the user belongs to - $teams = $this->user->teams; + $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; + $servers = $team->servers()->get(); foreach ($servers as $server) { - // Get applications - $serverApplications = $server->applications; + // Get applications (custom method returns Collection) + $serverApplications = $server->applications(); $applications = $applications->merge($serverApplications); - // Get databases - $serverDatabases = $this->getAllDatabasesForServer($server); + // Get databases (custom method returns Collection) + $serverDatabases = $server->databases(); $databases = $databases->merge($serverDatabases); - // Get services - $serverServices = $server->services; + // Get services (relationship needs ->get()) + $serverServices = $server->services()->get(); $services = $services->merge($serverServices); } } @@ -105,21 +128,4 @@ public function execute(): array return $deletedCounts; } - - private function getAllDatabasesForServer($server): Collection - { - $databases = collect(); - - // Get all standalone database types - $databases = $databases->merge($server->postgresqls); - $databases = $databases->merge($server->mysqls); - $databases = $databases->merge($server->mariadbs); - $databases = $databases->merge($server->mongodbs); - $databases = $databases->merge($server->redis); - $databases = $databases->merge($server->keydbs); - $databases = $databases->merge($server->dragonflies); - $databases = $databases->merge($server->clickhouses); - - return $databases; - } } diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php index d8caae54d..ca29dd49d 100644 --- a/app/Actions/User/DeleteUserServers.php +++ b/app/Actions/User/DeleteUserServers.php @@ -23,13 +23,13 @@ public function getServersPreview(): Collection $servers = collect(); // Get all teams the user belongs to - $teams = $this->user->teams; + $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; + $teamServers = $team->servers()->get(); $servers = $servers->merge($teamServers); } } 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/Cloud/CloudDeleteUser.php b/app/Console/Commands/Cloud/CloudDeleteUser.php deleted file mode 100644 index a2ea9b3e5..000000000 --- a/app/Console/Commands/Cloud/CloudDeleteUser.php +++ /dev/null @@ -1,744 +0,0 @@ -error('This command is only available on cloud instances.'); - - return 1; - } - - $email = $this->argument('email'); - $this->isDryRun = $this->option('dry-run'); - $this->skipStripe = $this->option('skip-stripe'); - $this->skipResources = $this->option('skip-resources'); - - if ($this->isDryRun) { - $this->info('๐Ÿ” DRY RUN MODE - No data will be deleted'); - $this->newLine(); - } - - try { - $this->user = User::whereEmail($email)->firstOrFail(); - } catch (\Exception $e) { - $this->error("User with email '{$email}' not found."); - - return 1; - } - - // Implement file lock to prevent concurrent deletions of the same user - $lockKey = "user_deletion_{$this->user->id}"; - $lock = Cache::lock($lockKey, 600); // 10 minute lock - - if (! $lock->get()) { - $this->error('Another deletion process is already running for this user. Please try again later.'); - $this->logAction("Deletion blocked for user {$email}: Another process is already running"); - - return 1; - } - - try { - $this->logAction("Starting user deletion process for: {$email}"); - - // Phase 1: Show User Overview (outside transaction) - if (! $this->showUserOverview()) { - $this->info('User deletion cancelled.'); - $lock->release(); - - return 0; - } - - // If not dry run, wrap everything in a transaction - if (! $this->isDryRun) { - try { - DB::beginTransaction(); - - // Phase 2: Delete Resources - if (! $this->skipResources) { - if (! $this->deleteResources()) { - DB::rollBack(); - $this->error('User deletion failed at resource deletion phase. All changes rolled back.'); - - return 1; - } - } - - // Phase 3: Delete Servers - if (! $this->deleteServers()) { - DB::rollBack(); - $this->error('User deletion failed at server deletion phase. All changes rolled back.'); - - return 1; - } - - // Phase 4: Handle Teams - if (! $this->handleTeams()) { - DB::rollBack(); - $this->error('User deletion failed at team handling phase. All changes rolled back.'); - - return 1; - } - - // Phase 5: Cancel Stripe Subscriptions - if (! $this->skipStripe && isCloud()) { - if (! $this->cancelStripeSubscriptions()) { - DB::rollBack(); - $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.'); - - return 1; - } - } - - // Phase 6: Delete User Profile - if (! $this->deleteUserProfile()) { - DB::rollBack(); - $this->error('User deletion failed at final phase. All changes rolled back.'); - - return 1; - } - - // Commit the transaction - DB::commit(); - - $this->newLine(); - $this->info('โœ… User deletion completed successfully!'); - $this->logAction("User deletion completed for: {$email}"); - - } catch (\Exception $e) { - DB::rollBack(); - $this->error('An error occurred during user deletion: '.$e->getMessage()); - $this->logAction("User deletion failed for {$email}: ".$e->getMessage()); - - return 1; - } - } else { - // Dry run mode - just run through the phases without transaction - // Phase 2: Delete Resources - if (! $this->skipResources) { - if (! $this->deleteResources()) { - $this->info('User deletion would be cancelled at resource deletion phase.'); - - return 0; - } - } - - // Phase 3: Delete Servers - if (! $this->deleteServers()) { - $this->info('User deletion would be cancelled at server deletion phase.'); - - return 0; - } - - // Phase 4: Handle Teams - if (! $this->handleTeams()) { - $this->info('User deletion would be cancelled at team handling phase.'); - - return 0; - } - - // Phase 5: Cancel Stripe Subscriptions - if (! $this->skipStripe && isCloud()) { - if (! $this->cancelStripeSubscriptions()) { - $this->info('User deletion would be cancelled at Stripe cancellation phase.'); - - return 0; - } - } - - // Phase 6: Delete User Profile - if (! $this->deleteUserProfile()) { - $this->info('User deletion would be cancelled at final phase.'); - - return 0; - } - - $this->newLine(); - $this->info('โœ… DRY RUN completed successfully! No data was deleted.'); - } - - return 0; - } finally { - // Ensure lock is always released - $lock->release(); - } - } - - private function showUserOverview(): bool - { - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->info('PHASE 1: USER OVERVIEW'); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - $teams = $this->user->teams; - $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner'); - $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner'); - - // Collect all servers from all teams - $allServers = collect(); - $allApplications = collect(); - $allDatabases = collect(); - $allServices = collect(); - $activeSubscriptions = collect(); - - foreach ($teams as $team) { - $servers = $team->servers; - $allServers = $allServers->merge($servers); - - foreach ($servers as $server) { - $resources = $server->definedResources(); - foreach ($resources as $resource) { - if ($resource instanceof \App\Models\Application) { - $allApplications->push($resource); - } elseif ($resource instanceof \App\Models\Service) { - $allServices->push($resource); - } else { - $allDatabases->push($resource); - } - } - } - - if ($team->subscription && $team->subscription->stripe_subscription_id) { - $activeSubscriptions->push($team->subscription); - } - } - - $this->table( - ['Property', 'Value'], - [ - ['User', $this->user->email], - ['User ID', $this->user->id], - ['Created', $this->user->created_at->format('Y-m-d H:i:s')], - ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')], - ['Teams (Total)', $teams->count()], - ['Teams (Owner)', $ownedTeams->count()], - ['Teams (Member)', $memberTeams->count()], - ['Servers', $allServers->unique('id')->count()], - ['Applications', $allApplications->count()], - ['Databases', $allDatabases->count()], - ['Services', $allServices->count()], - ['Active Stripe Subscriptions', $activeSubscriptions->count()], - ] - ); - - $this->newLine(); - - $this->warn('โš ๏ธ WARNING: This will permanently delete the user and all associated data!'); - $this->newLine(); - - if (! $this->confirm('Do you want to continue with the deletion process?', false)) { - return false; - } - - return true; - } - - private function deleteResources(): bool - { - $this->newLine(); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->info('PHASE 2: DELETE RESOURCES'); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - $action = new DeleteUserResources($this->user, $this->isDryRun); - $resources = $action->getResourcesPreview(); - - if ($resources['applications']->isEmpty() && - $resources['databases']->isEmpty() && - $resources['services']->isEmpty()) { - $this->info('No resources to delete.'); - - return true; - } - - $this->info('Resources to be deleted:'); - $this->newLine(); - - if ($resources['applications']->isNotEmpty()) { - $this->warn("Applications to be deleted ({$resources['applications']->count()}):"); - $this->table( - ['Name', 'UUID', 'Server', 'Status'], - $resources['applications']->map(function ($app) { - return [ - $app->name, - $app->uuid, - $app->destination->server->name, - $app->status ?? 'unknown', - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($resources['databases']->isNotEmpty()) { - $this->warn("Databases to be deleted ({$resources['databases']->count()}):"); - $this->table( - ['Name', 'Type', 'UUID', 'Server'], - $resources['databases']->map(function ($db) { - return [ - $db->name, - class_basename($db), - $db->uuid, - $db->destination->server->name, - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($resources['services']->isNotEmpty()) { - $this->warn("Services to be deleted ({$resources['services']->count()}):"); - $this->table( - ['Name', 'UUID', 'Server'], - $resources['services']->map(function ($service) { - return [ - $service->name, - $service->uuid, - $service->server->name, - ]; - })->toArray() - ); - $this->newLine(); - } - - $this->error('โš ๏ธ THIS ACTION CANNOT BE UNDONE!'); - if (! $this->confirm('Are you sure you want to delete all these resources?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Deleting resources...'); - $result = $action->execute(); - $this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services"); - $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services"); - } - - return true; - } - - private function deleteServers(): bool - { - $this->newLine(); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->info('PHASE 3: DELETE SERVERS'); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - $action = new DeleteUserServers($this->user, $this->isDryRun); - $servers = $action->getServersPreview(); - - if ($servers->isEmpty()) { - $this->info('No servers to delete.'); - - return true; - } - - $this->warn("Servers to be deleted ({$servers->count()}):"); - $this->table( - ['ID', 'Name', 'IP', 'Description', 'Resources Count'], - $servers->map(function ($server) { - $resourceCount = $server->definedResources()->count(); - - return [ - $server->id, - $server->name, - $server->ip, - $server->description ?? '-', - $resourceCount, - ]; - })->toArray() - ); - $this->newLine(); - - $this->error('โš ๏ธ WARNING: Deleting servers will remove all server configurations!'); - if (! $this->confirm('Are you sure you want to delete all these servers?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Deleting servers...'); - $result = $action->execute(); - $this->info("Deleted {$result['servers']} servers"); - $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}"); - } - - return true; - } - - private function handleTeams(): bool - { - $this->newLine(); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->info('PHASE 4: HANDLE TEAMS'); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - $action = new DeleteUserTeams($this->user, $this->isDryRun); - $preview = $action->getTeamsPreview(); - - // Check for edge cases first - EXIT IMMEDIATELY if found - if ($preview['edge_cases']->isNotEmpty()) { - $this->error('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->error('โš ๏ธ EDGE CASES DETECTED - CANNOT PROCEED'); - $this->error('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - foreach ($preview['edge_cases'] as $edgeCase) { - $team = $edgeCase['team']; - $reason = $edgeCase['reason']; - $this->error("Team: {$team->name} (ID: {$team->id})"); - $this->error("Issue: {$reason}"); - - // Show team members for context - $this->info('Current members:'); - foreach ($team->members as $member) { - $role = $member->pivot->role; - $this->line(" - {$member->name} ({$member->email}) - Role: {$role}"); - } - - // Check for active resources - $resourceCount = 0; - foreach ($team->servers as $server) { - $resources = $server->definedResources(); - $resourceCount += $resources->count(); - } - - if ($resourceCount > 0) { - $this->warn(" โš ๏ธ This team has {$resourceCount} active resources!"); - } - - // Show subscription details if relevant - if ($team->subscription && $team->subscription->stripe_subscription_id) { - $this->warn(' โš ๏ธ Active Stripe subscription details:'); - $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}"); - $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}"); - - // Show other owners who could potentially take over - $otherOwners = $team->members - ->where('id', '!=', $this->user->id) - ->filter(function ($member) { - return $member->pivot->role === 'owner'; - }); - - if ($otherOwners->isNotEmpty()) { - $this->info(' Other owners who could take over billing:'); - foreach ($otherOwners as $owner) { - $this->line(" - {$owner->name} ({$owner->email})"); - } - } - } - - $this->newLine(); - } - - $this->error('Please resolve these issues manually before retrying:'); - - // Check if any edge case involves subscription payment issues - $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) { - return str_contains($edgeCase['reason'], 'Stripe subscription'); - }); - - if ($hasSubscriptionIssue) { - $this->info('For teams with subscription payment issues:'); - $this->info('1. Cancel the subscription through Stripe dashboard, OR'); - $this->info('2. Transfer the subscription to another owner\'s payment method, OR'); - $this->info('3. Have the other owner create a new subscription after cancelling this one'); - $this->newLine(); - } - - $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) { - return str_contains($edgeCase['reason'], 'No suitable owner replacement'); - }); - - if ($hasNoOwnerReplacement) { - $this->info('For teams with no suitable owner replacement:'); - $this->info('1. Assign an admin role to a trusted member, OR'); - $this->info('2. Transfer team resources to another team, OR'); - $this->info('3. Delete the team manually if no longer needed'); - $this->newLine(); - } - - $this->error('USER DELETION ABORTED DUE TO EDGE CASES'); - $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling"); - - // Exit immediately - don't proceed with deletion - if (! $this->isDryRun) { - DB::rollBack(); - } - exit(1); - } - - if ($preview['to_delete']->isEmpty() && - $preview['to_transfer']->isEmpty() && - $preview['to_leave']->isEmpty()) { - $this->info('No team changes needed.'); - - return true; - } - - if ($preview['to_delete']->isNotEmpty()) { - $this->warn('Teams to be DELETED (user is the only member):'); - $this->table( - ['ID', 'Name', 'Resources', 'Subscription'], - $preview['to_delete']->map(function ($team) { - $resourceCount = 0; - foreach ($team->servers as $server) { - $resourceCount += $server->definedResources()->count(); - } - $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id - ? 'โš ๏ธ YES - '.$team->subscription->stripe_subscription_id - : 'No'; - - return [ - $team->id, - $team->name, - $resourceCount, - $hasSubscription, - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($preview['to_transfer']->isNotEmpty()) { - $this->warn('Teams where ownership will be TRANSFERRED:'); - $this->table( - ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'], - $preview['to_transfer']->map(function ($item) { - return [ - $item['team']->id, - $item['team']->name, - $item['new_owner']->name, - $item['new_owner']->email, - ]; - })->toArray() - ); - $this->newLine(); - } - - if ($preview['to_leave']->isNotEmpty()) { - $this->warn('Teams where user will be REMOVED (other owners/admins exist):'); - $userId = $this->user->id; - $this->table( - ['ID', 'Name', 'User Role', 'Other Members'], - $preview['to_leave']->map(function ($team) use ($userId) { - $userRole = $team->members->where('id', $userId)->first()->pivot->role; - $otherMembers = $team->members->count() - 1; - - return [ - $team->id, - $team->name, - $userRole, - $otherMembers, - ]; - })->toArray() - ); - $this->newLine(); - } - - $this->error('โš ๏ธ WARNING: Team changes affect access control and ownership!'); - if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Processing team changes...'); - $result = $action->execute(); - $this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}"); - $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}"); - } - - return true; - } - - private function cancelStripeSubscriptions(): bool - { - $this->newLine(); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS'); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - $action = new CancelSubscription($this->user, $this->isDryRun); - $subscriptions = $action->getSubscriptionsPreview(); - - if ($subscriptions->isEmpty()) { - $this->info('No Stripe subscriptions to cancel.'); - - return true; - } - - $this->info('Stripe subscriptions to cancel:'); - $this->newLine(); - - $totalMonthlyValue = 0; - foreach ($subscriptions as $subscription) { - $team = $subscription->team; - $planId = $subscription->stripe_plan_id; - - // Try to get the price from config - $monthlyValue = $this->getSubscriptionMonthlyValue($planId); - $totalMonthlyValue += $monthlyValue; - - $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})"); - if ($monthlyValue > 0) { - $this->line(" Monthly value: \${$monthlyValue}"); - } - if ($subscription->stripe_cancel_at_period_end) { - $this->line(' โš ๏ธ Already set to cancel at period end'); - } - } - - if ($totalMonthlyValue > 0) { - $this->newLine(); - $this->warn("Total monthly value: \${$totalMonthlyValue}"); - } - $this->newLine(); - - $this->error('โš ๏ธ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!'); - if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) { - return false; - } - - if (! $this->isDryRun) { - $this->info('Cancelling subscriptions...'); - $result = $action->execute(); - $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed"); - if ($result['failed'] > 0 && ! empty($result['errors'])) { - $this->error('Failed subscriptions:'); - foreach ($result['errors'] as $error) { - $this->error(" - {$error}"); - } - } - $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}"); - } - - return true; - } - - private function deleteUserProfile(): bool - { - $this->newLine(); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->info('PHASE 6: DELETE USER PROFILE'); - $this->info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - $this->newLine(); - - $this->warn('โš ๏ธ FINAL STEP - This action is IRREVERSIBLE!'); - $this->newLine(); - - $this->info('User profile to be deleted:'); - $this->table( - ['Property', 'Value'], - [ - ['Email', $this->user->email], - ['Name', $this->user->name], - ['User ID', $this->user->id], - ['Created', $this->user->created_at->format('Y-m-d H:i:s')], - ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'], - ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'], - ] - ); - - $this->newLine(); - - $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:"); - $confirmation = $this->ask('Confirmation'); - - if ($confirmation !== "DELETE {$this->user->email}") { - $this->error('Confirmation text does not match. Deletion cancelled.'); - - return false; - } - - if (! $this->isDryRun) { - $this->info('Deleting user profile...'); - - try { - $this->user->delete(); - $this->info('User profile deleted successfully.'); - $this->logAction("User profile deleted: {$this->user->email}"); - } catch (\Exception $e) { - $this->error('Failed to delete user profile: '.$e->getMessage()); - $this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage()); - - return false; - } - } - - return true; - } - - private function getSubscriptionMonthlyValue(string $planId): int - { - // 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); - } -} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7b85408cf..46282fddb 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -597,6 +597,224 @@ 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', diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c4d603392..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.', diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 7ddbaf991..f6a6b3513 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -12,6 +12,88 @@ class GithubController extends Controller { + private function removeSensitiveData($githubApp) + { + $githubApp->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.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 737724d22..b3565a933 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -328,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, @@ -338,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) { @@ -462,6 +476,18 @@ public function create_service(Request $request) $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; @@ -777,6 +803,19 @@ public function update_by_uuid(Request $request) } $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); + } + $service->docker_compose_raw = $dockerComposeRaw; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e9d7b82b2..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, diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index c9c58bddc..f0b9d67f2 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -2,10 +2,44 @@ namespace App\Http\Middleware; +use App\Models\InstanceSettings; use Illuminate\Http\Middleware\TrustHosts as Middleware; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Spatie\Url\Url; class TrustHosts extends Middleware { + /** + * Handle the incoming request. + * + * Skip host validation for certain routes: + * - Terminal auth routes (called by realtime container) + * - API routes (use token-based authentication, not host validation) + * - Webhook endpoints (use cryptographic signature validation) + */ + public function handle(Request $request, $next) + { + // Skip host validation for these routes + if ($request->is( + 'terminal/auth', + 'terminal/auth/ips', + 'api/*', + 'webhooks/*' + )) { + return $next($request); + } + + // Skip host validation if no FQDN is configured (initial setup) + $fqdnHost = Cache::get('instance_settings_fqdn_host'); + if ($fqdnHost === '' || $fqdnHost === null) { + return $next($request); + } + + // For all other routes, use parent's host validation + return parent::handle($request, $next); + } + /** * Get the host patterns that should be trusted. * @@ -13,8 +47,50 @@ 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 the APP_URL host itself (not just subdomains) + $appUrl = config('app.url'); + if ($appUrl) { + try { + $appUrlHost = parse_url($appUrl, PHP_URL_HOST); + if ($appUrlHost && ! in_array($appUrlHost, $trustedHosts, true)) { + $trustedHosts[] = $appUrlHost; + } + } catch (\Exception $e) { + // Ignore parse errors + } + } + + // Trust all subdomains of APP_URL as fallback + $trustedHosts[] = $this->allSubdomainsOfApplicationUrl(); + + return array_filter($trustedHosts); } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 94c299364..dbb91aa24 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -517,6 +517,10 @@ private function deploy_dockerimage_buildpack() $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); + + // Save runtime environment variables (including empty .env file if no variables defined) + $this->save_runtime_environment_variables(); + $this->rolling_update(); } @@ -1222,9 +1226,9 @@ private function save_runtime_environment_variables() // Handle empty environment variables if ($environment_variables->isEmpty()) { - // For Docker Compose, we need to create an empty .env file + // For Docker Compose and Docker Image, we need to create an empty .env file // because we always reference it in the compose file - if ($this->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerimage') { $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); // Create empty .env file @@ -1319,12 +1323,18 @@ private function save_runtime_environment_variables() 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.'='.$item); + $envs->push($key.'='.escapeBashEnvValue($item)); }); // Add SERVICE_NAME variables for Docker Compose builds @@ -1338,7 +1348,7 @@ private function generate_buildtime_environment_variables() } $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { - $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName)); } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments @@ -1351,8 +1361,8 @@ private function generate_buildtime_environment_variables() $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('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); } } } else { @@ -1360,7 +1370,7 @@ private function generate_buildtime_environment_variables() $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)); + $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 @@ -1373,8 +1383,8 @@ private function generate_buildtime_environment_variables() $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('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); } } } @@ -1396,7 +1406,32 @@ private function generate_buildtime_environment_variables() } foreach ($sorted_environment_variables as $env) { - $envs->push($env->key.'='.$env->real_value); + // 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() @@ -1413,11 +1448,42 @@ private function generate_buildtime_environment_variables() } foreach ($sorted_environment_variables as $env) { - $envs->push($env->key.'='.$env->real_value); + // 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; } @@ -1566,7 +1632,7 @@ private function health_check() return; } if ($this->application->custom_healthcheck_found) { - $this->application_deployment_queue->addLogEntry('Custom healthcheck found, skipping default healthcheck.'); + $this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.'); } if ($this->container_name) { $counter = 1; @@ -2292,16 +2358,22 @@ private function generate_compose_file() ]; // Always use .env file $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; - $docker_compose['services'][$this->container_name]['healthcheck'] = [ - 'test' => [ - 'CMD-SHELL', - $this->generate_healthcheck_commands(), - ], - 'interval' => $this->application->health_check_interval.'s', - 'timeout' => $this->application->health_check_timeout.'s', - 'retries' => $this->application->health_check_retries, - 'start_period' => $this->application->health_check_start_period.'s', - ]; + + // Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile + // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used + // If healthcheck is disabled, no healthcheck will be added + if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { + $docker_compose['services'][$this->container_name]['healthcheck'] = [ + 'test' => [ + 'CMD-SHELL', + $this->generate_healthcheck_commands(), + ], + 'interval' => $this->application->health_check_interval.'s', + 'timeout' => $this->application->health_check_timeout.'s', + 'retries' => $this->application->health_check_retries, + 'start_period' => $this->application->health_check_start_period.'s', + ]; + } if (! is_null($this->application->limits_cpuset)) { data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset); diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index a92f53b39..d2e3cc964 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -35,6 +35,9 @@ public function handle() if ($this->application->is_public_repository()) { return; } + + $serviceName = $this->application->name; + if ($this->status === ProcessStatus::CLOSED) { $this->delete_comment(); @@ -42,12 +45,12 @@ public function handle() } match ($this->status) { - ProcessStatus::QUEUED => $this->body = "The preview deployment is queued. โณ\n\n", - ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment is in progress. ๐ŸŸก\n\n", - ProcessStatus::FINISHED => $this->body = "The preview deployment is ready. ๐ŸŸข\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''), - ProcessStatus::ERROR => $this->body = "The preview deployment failed. ๐Ÿ”ด\n\n", - ProcessStatus::KILLED => $this->body = "The preview deployment was killed. โšซ\n\n", - ProcessStatus::CANCELLED => $this->body = "The preview deployment was cancelled. ๐Ÿšซ\n\n", + 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}"; 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/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/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b42f29fa5..a733d8cb3 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -3,6 +3,7 @@ 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; @@ -14,6 +15,7 @@ class General extends Component { use AuthorizesRequests; + use SynchronizesModelData; public string $applicationId; @@ -23,6 +25,8 @@ class General extends Component public string $name; + public ?string $description = null; + public ?string $fqdn = null; public string $git_repository; @@ -31,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; @@ -66,50 +138,50 @@ class General extends Component protected function rules(): array { return [ - 'application.name' => ValidationPatterns::nameRules(), - 'application.description' => ValidationPatterns::descriptionRules(), - 'application.fqdn' => 'nullable', - 'application.git_repository' => 'required', - 'application.git_branch' => 'required', - 'application.git_commit_sha' => 'nullable', - 'application.install_command' => 'nullable', - 'application.build_command' => 'nullable', - 'application.start_command' => 'nullable', - 'application.build_pack' => 'required', - 'application.static_image' => 'required', - 'application.base_directory' => 'required', - 'application.publish_directory' => 'nullable', - 'application.ports_exposes' => 'required', - 'application.ports_mappings' => 'nullable', - 'application.custom_network_aliases' => 'nullable', - 'application.dockerfile' => 'nullable', - 'application.docker_registry_image_name' => 'nullable', - 'application.docker_registry_image_tag' => 'nullable', - 'application.dockerfile_location' => 'nullable', - 'application.docker_compose_location' => 'nullable', - 'application.docker_compose' => 'nullable', - 'application.docker_compose_raw' => 'nullable', - 'application.dockerfile_target_build' => 'nullable', - 'application.docker_compose_custom_start_command' => 'nullable', - 'application.docker_compose_custom_build_command' => 'nullable', - 'application.custom_labels' => 'nullable', - 'application.custom_docker_run_options' => 'nullable', - 'application.pre_deployment_command' => 'nullable', - 'application.pre_deployment_command_container' => 'nullable', - 'application.post_deployment_command' => 'nullable', - 'application.post_deployment_command_container' => 'nullable', - 'application.custom_nginx_configuration' => 'nullable', - 'application.settings.is_static' => 'boolean|required', - 'application.settings.is_spa' => 'boolean|required', - 'application.settings.is_build_server_enabled' => 'boolean|required', - 'application.settings.is_container_label_escape_enabled' => 'boolean|required', - 'application.settings.is_container_label_readonly_enabled' => 'boolean|required', - 'application.settings.is_preserve_repository_enabled' => 'boolean|required', - 'application.is_http_basic_auth_enabled' => 'boolean|required', - 'application.http_basic_auth_username' => 'string|nullable', - 'application.http_basic_auth_password' => 'string|nullable', - 'application.watch_paths' => 'nullable', - 'application.redirect' => 'string|required', + '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', ]; } @@ -118,31 +190,31 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'application.name.required' => 'The Name field is required.', - 'application.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'application.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'application.git_repository.required' => 'The Git Repository field is required.', - 'application.git_branch.required' => 'The Git Branch field is required.', - 'application.build_pack.required' => 'The Build Pack field is required.', - 'application.static_image.required' => 'The Static Image field is required.', - 'application.base_directory.required' => 'The Base Directory field is required.', - 'application.ports_exposes.required' => 'The Exposed Ports field is required.', - 'application.settings.is_static.required' => 'The Static setting is required.', - 'application.settings.is_static.boolean' => 'The Static setting must be true or false.', - 'application.settings.is_spa.required' => 'The SPA setting is required.', - 'application.settings.is_spa.boolean' => 'The SPA setting must be true or false.', - 'application.settings.is_build_server_enabled.required' => 'The Build Server setting is required.', - 'application.settings.is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.', - 'application.settings.is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.', - 'application.settings.is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.', - 'application.settings.is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.', - 'application.settings.is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.', - 'application.settings.is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.', - 'application.settings.is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.', - 'application.is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.', - 'application.is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.', - 'application.redirect.required' => 'The Redirect setting is required.', - 'application.redirect.string' => 'The Redirect setting must be a string.', + '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.', ] ); } @@ -193,11 +265,15 @@ 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') { // Only update if user has permission @@ -218,9 +294,6 @@ public function mount() } $this->parsedServiceDomains = $sanitizedDomains; - $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; $this->customLabels = $this->application->parseContainerLabels(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) { // Only update custom labels if user has permission @@ -249,6 +322,60 @@ public function mount() 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() @@ -256,6 +383,12 @@ public function instantSave() try { $this->authorize('update', $this->application); + $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'); } @@ -265,20 +398,21 @@ public function instantSave() $this->application->settings->save(); $this->dispatch('success', 'Settings saved.'); $this->application->refresh(); + $this->syncFromModel(); // 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) { + if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->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) { + 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->application->settings->is_preserve_repository_enabled; + $storage->is_based_on_git = $this->is_preserve_repository_enabled; $storage->save(); }); } } - if ($this->application->settings->is_container_label_readonly_enabled) { + if ($this->is_container_label_readonly_enabled) { $this->resetDefaultLabels(false); } } catch (\Throwable $e) { @@ -366,21 +500,21 @@ public function generateDomain(string $serviceName) } } - 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() { // Check if user has permission to update try { @@ -388,21 +522,28 @@ public function updatedApplicationBuildPack() } catch (\Illuminate\Auth\Access\AuthorizationException $e) { // User doesn't have permission, revert the change and return $this->application->refresh(); + $this->syncFromModel(); return; } - if ($this->application->build_pack !== 'nixpacks') { + // 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') { + 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) { @@ -421,8 +562,9 @@ public function updatedApplicationBuildPack() $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(); } @@ -438,8 +580,11 @@ public function getWildcardDomain() $server = data_get($this->application, 'destination.server'); if ($server) { $fqdn = generateUrl(server: $server, random: $this->application->uuid); - $this->application->fqdn = $fqdn; + $this->fqdn = $fqdn; + $this->syncToModel(); $this->application->save(); + $this->application->refresh(); + $this->syncFromModel(); $this->resetDefaultLabels(); $this->dispatch('success', 'Wildcard domain generated.'); } @@ -453,8 +598,11 @@ public function generateNginxConfiguration($type = 'static') try { $this->authorize('update', $this->application); - $this->application->custom_nginx_configuration = defaultNginxConfiguration($type); + $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); @@ -464,15 +612,16 @@ public function generateNginxConfiguration($type = 'static') 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->application->refresh(); + $this->syncFromModel(); + if ($this->build_pack === 'dockercompose') { $this->loadComposeFile(showToast: false); } $this->dispatch('configurationChanged'); @@ -483,8 +632,8 @@ 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 (! validateDNSEntry($domain, $this->application->destination->server)) { @@ -507,7 +656,8 @@ public function checkFqdns($showToaster = true) $this->forceSaveDomains = false; } - $this->application->fqdn = $domains->implode(','); + $this->fqdn = $domains->implode(','); + $this->application->fqdn = $this->fqdn; $this->resetDefaultLabels(false); } @@ -547,21 +697,27 @@ public function submit($showToaster = true) $this->validate(); - $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) { + $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)->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(); @@ -581,38 +737,42 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) { + 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; } } - 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); if ($this->application->isDirty('docker_compose_domains')) { foreach ($this->parsedServiceDomains as $service) { @@ -643,12 +803,12 @@ public function submit($showToaster = true) } $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 { diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 1cb2ef2c5..e28c8142d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -33,14 +33,34 @@ class Previews extends Component 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() @@ -73,35 +93,52 @@ public function save_preview($preview_id) $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 (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) { - $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

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

Check this documentation for further help."); - $success = false; - } - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - $this->pendingPreviewId = $preview_id; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - } if (! $preview) { 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); } @@ -121,6 +158,7 @@ public function generate_preview($preview_id) if ($this->application->build_pack === 'dockercompose') { $preview->generate_preview_fqdn_compose(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('success', 'Domain generated.'); return; @@ -128,6 +166,7 @@ public function generate_preview($preview_id) $preview->generate_preview_fqdn(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('update_links'); $this->dispatch('success', 'Domain generated.'); } catch (\Throwable $e) { @@ -152,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(); @@ -164,6 +204,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) } $found->generate_preview_fqdn(); $this->application->refresh(); + $this->syncData(false); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index cfb364b6d..942dfeb37 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -18,6 +18,13 @@ class PreviewsCompose extends Component 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'); @@ -28,10 +35,10 @@ public function save() try { $this->authorize('update', $this->preview->application); - $domain = data_get($this->service, 'domain'); $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $domain; + $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'); @@ -46,7 +53,7 @@ public function generate() try { $this->authorize('update', $this->preview->application); - $domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect(); + $domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []); $domain = $domains->first(function ($_, $key) { return $key === $this->serviceName; }); @@ -68,24 +75,40 @@ public function generate() $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; } else { // Use the existing domain from the main application - $url = Url::fromString($domain_string); + // Handle multiple domains separated by commas + $domain_list = explode(',', $domain_string); + $preview_fqdns = []; $template = $this->preview->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; $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 = str_replace('{{port}}', $port, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + + 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(); diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 513ba9f16..7c64a6eef 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -9,6 +9,7 @@ class Configuration extends Component { use AuthorizesRequests; + public $currentRoute; public $database; diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 5cda1dedd..a88a62d88 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -37,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(); diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index abf4c45a7..4bcf866d3 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -24,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() @@ -50,11 +64,33 @@ public function mount() $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; + } + } + public function delete($password) { try { @@ -92,7 +128,7 @@ public function instantSaveLogDrain() try { $this->authorize('update', $this->database); if (! $this->database->service->destination->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; @@ -145,15 +181,17 @@ public function instantSave() { try { $this->authorize('update', $this->database); - if ($this->database->is_public && ! $this->database->public_port) { + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; + $this->isPublic = false; return; } + $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; @@ -182,7 +220,10 @@ 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 $e) { 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 dbbcca8f8..f759dd71e 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -2,12 +2,14 @@ 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; @@ -18,14 +20,24 @@ class EditDomain extends Component 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() @@ -38,19 +50,22 @@ public function confirmDomainUsage() 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)->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')); } + // 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); @@ -67,6 +82,8 @@ public function submit() $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.'); @@ -79,6 +96,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/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 7f0caaba3..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; @@ -22,7 +23,7 @@ class FileStorage extends Component { - use AuthorizesRequests; + use AuthorizesRequests, SynchronizesModelData; public LocalFileVolume $fileStorage; @@ -36,12 +37,16 @@ class FileStorage extends Component 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() @@ -56,6 +61,15 @@ public function mount() } $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->syncFromModel(); + } + + protected function getModelBindings(): array + { + return [ + 'content' => 'fileStorage.content', + 'isBasedOnGit' => 'fileStorage.is_based_on_git', + ]; } public function convertToDirectory() @@ -82,6 +96,7 @@ public function loadStorageOnServer() $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); @@ -148,14 +163,16 @@ public function submit() 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/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index e37b6ad86..20358218f 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Concerns\SynchronizesModelData; use App\Models\InstanceSettings; use App\Models\ServiceApplication; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -14,6 +15,7 @@ class ServiceApplicationView extends Component { use AuthorizesRequests; + use SynchronizesModelData; public ServiceApplication $application; @@ -29,16 +31,32 @@ class ServiceApplicationView extends Component 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() @@ -56,11 +74,12 @@ public function instantSaveAdvanced() try { $this->authorize('update', $this->application); if (! $this->application->service->destination->server->isLogDrainEnabled()) { - $this->application->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->syncToModel(); $this->application->save(); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (\Throwable $e) { @@ -95,11 +114,26 @@ public function mount() 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 { @@ -146,19 +180,21 @@ public function submit() { try { $this->authorize('update', $this->application); - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $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)->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')); } + // 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); @@ -175,6 +211,8 @@ public function submit() $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.'); @@ -186,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 1961a7985..85cd21a7f 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -15,14 +15,25 @@ class StackForm extends Component protected $listeners = ['saveCompose']; + // Explicit properties + public string $name; + + public ?string $description = null; + + public string $dockerComposeRaw; + + public string $dockerCompose; + + public ?bool $connectToDockerNetwork = null; + protected function rules(): array { $baseRules = [ - 'service.docker_compose_raw' => 'required', - 'service.docker_compose' => 'required', - 'service.name' => ValidationPatterns::nameRules(), - 'service.description' => ValidationPatterns::descriptionRules(), - 'service.connect_to_docker_network' => 'nullable', + 'dockerComposeRaw' => 'required', + 'dockerCompose' => 'required', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'connectToDockerNetwork' => 'nullable', ]; // Add dynamic field rules @@ -39,19 +50,44 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'service.name.required' => 'The Name field is required.', - 'service.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'service.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'service.docker_compose_raw.required' => 'The Docker Compose Raw field is required.', - 'service.docker_compose.required' => 'The Docker Compose field is required.', + '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) { @@ -87,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.'); } @@ -101,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/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index c0714fe03..c8029761d 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,35 +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.'); } @@ -40,6 +95,8 @@ 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) { @@ -51,14 +108,16 @@ public function toggleHealthcheck() { try { $this->authorize('update', $this->resource); - $wasEnabled = $this->resource->health_check_enabled; - $this->resource->health_check_enabled = ! $this->resource->health_check_enabled; + $wasEnabled = $this->healthCheckEnabled; + $this->healthCheckEnabled = ! $this->healthCheckEnabled; + + $this->syncToModel(); $this->resource->save(); - if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) { + 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->resource->health_check_enabled ? 'enabled' : 'disabled').'.'); + $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.'); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 196badec8..0b3840289 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -11,52 +11,105 @@ class ResourceLimits extends Component 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 { $this->authorize('update', $this->resource); - if (! $this->resource->limits_memory) { - $this->resource->limits_memory = '0'; + + // 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/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 4f57cbfa6..5970ec904 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -25,20 +25,48 @@ 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(); } @@ -47,6 +75,7 @@ public function submit() $this->authorize('update', $this->resource); $this->validate(); + $this->syncData(true); $this->storage->save(); $this->dispatch('success', 'Storage updated successfully'); } diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 950ec152d..1eb66ae3e 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function render() { - $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description'])->get(); + $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); return view('livewire.security.private-key.index', [ 'privateKeys' => $privateKeys, diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 2ff06c349..c292d14a3 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -13,15 +13,24 @@ class Show extends Component 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 function rules(): array { return [ - 'private_key.name' => ValidationPatterns::nameRules(), - 'private_key.description' => ValidationPatterns::descriptionRules(), - 'private_key.private_key' => 'required|string', - 'private_key.is_git_related' => 'nullable|boolean', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'privateKeyValue' => 'required|string', + 'isGitRelated' => 'nullable|boolean', ]; } @@ -30,25 +39,54 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'private_key.name.required' => 'The Name field is required.', - 'private_key.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'private_key.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'private_key.private_key.required' => 'The Private Key field is required.', - 'private_key.private_key.string' => 'The Private Key must be a valid string.', + '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->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + + // Explicit authorization check - will throw 403 if not authorized + $this->authorize('view', $this->private_key); + + $this->syncData(false); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { abort(404); } @@ -81,6 +119,10 @@ 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/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index a77c5df78..b7cf18b0d 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -290,7 +290,7 @@ private function loadHetznerData(string $token) } } - private function getCpuVendorInfo(array $serverType): string|null + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); @@ -299,9 +299,9 @@ private function getCpuVendorInfo(array $serverType): string|null } elseif (str_starts_with($name, 'cpx')) { return 'AMD EPYCโ„ข'; } elseif (str_starts_with($name, 'cx')) { - return 'Intelยฎ Xeonยฎ'; + return 'Intelยฎ/AMD'; } elseif (str_starts_with($name, 'cax')) { - return 'Ampereยฎ Altraยฎ'; + return 'Ampereยฎ'; } return null; diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 5ef559862..bc7e9bde4 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -22,6 +22,8 @@ class Proxy extends Component public ?string $redirectUrl = null; + public bool $generateExactLabels = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -33,7 +35,7 @@ public function getListeners() } protected $rules = [ - 'server.settings.generate_exact_labels' => 'required|boolean', + 'generateExactLabels' => 'required|boolean', ]; public function mount() @@ -41,6 +43,16 @@ public function mount() $this->selectedProxy = $this->server->proxyType(); $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); + $this->syncData(false); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->server->settings->generate_exact_labels = $this->generateExactLabels; + } else { + $this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false; + } } public function getConfigurationFilePathProperty() @@ -75,6 +87,7 @@ 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) { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 9ad5444b9..351407dac 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -34,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() @@ -69,6 +97,52 @@ 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 { @@ -126,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; @@ -247,21 +325,9 @@ public function submit() $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) { @@ -286,6 +352,8 @@ public function instantSave() $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) { diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 9438b7727..d97550693 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -14,17 +14,34 @@ class Form extends Component public S3Storage $storage; + // 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 [ - 'storage.is_usable' => 'nullable|boolean', - 'storage.name' => ValidationPatterns::nameRules(required: false), - 'storage.description' => ValidationPatterns::descriptionRules(), - 'storage.region' => 'required|max:255', - 'storage.key' => 'required|max:255', - 'storage.secret' => 'required|max:255', - 'storage.bucket' => 'required|max:255', - 'storage.endpoint' => 'required|url|max:255', + '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', ]; } @@ -33,34 +50,69 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'storage.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'storage.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', - 'storage.region.required' => 'The Region field is required.', - 'storage.region.max' => 'The Region may not be greater than 255 characters.', - 'storage.key.required' => 'The Access Key field is required.', - 'storage.key.max' => 'The Access Key may not be greater than 255 characters.', - 'storage.secret.required' => 'The Secret Key field is required.', - 'storage.secret.max' => 'The Secret Key may not be greater than 255 characters.', - 'storage.bucket.required' => 'The Bucket field is required.', - 'storage.bucket.max' => 'The Bucket may not be greater than 255 characters.', - 'storage.endpoint.required' => 'The Endpoint field is required.', - 'storage.endpoint.url' => 'The Endpoint must be a valid URL.', - 'storage.endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + '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', ]; + /** + * 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 { @@ -94,6 +146,9 @@ public function submit() 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 @@ -103,12 +158,16 @@ public function submit() $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/Team/Index.php b/app/Livewire/Team/Index.php index 8b9b70e14..e4daad311 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -18,11 +18,16 @@ class Index extends Component public Team $team; + // Explicit properties + public string $name; + + public ?string $description = null; + protected function rules(): array { return [ - 'team.name' => ValidationPatterns::nameRules(), - 'team.description' => ValidationPatterns::descriptionRules(), + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), ]; } @@ -31,21 +36,40 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ - 'team.name.required' => 'The Name field is required.', - 'team.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', - 'team.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + '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(); @@ -62,6 +86,7 @@ public function submit() $this->validate(); try { $this->authorize('update', $this->team); + $this->syncData(true); $this->team->save(); refreshSession(); $this->dispatch('success', 'Team updated.'); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 45f7e467f..45af53950 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -45,9 +45,16 @@ 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'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 33c1b7fc4..32459f752 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1064,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; } } @@ -1100,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([ @@ -1117,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 [ @@ -1795,7 +1804,22 @@ public function getFilesFromServer(bool $isInit = false) public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false) { $dockerfile = str($dockerfile)->trim()->explode("\n"); - if (str($dockerfile)->contains('HEALTHCHECK') && ($this->isHealthcheckDisabled() || $isInit)) { + $hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK'); + + // Always check if healthcheck was removed, regardless of health_check_enabled setting + if (! $hasHealthcheck && $this->custom_healthcheck_found) { + // HEALTHCHECK was removed from Dockerfile, reset to defaults + $this->custom_healthcheck_found = false; + $this->health_check_interval = 5; + $this->health_check_timeout = 5; + $this->health_check_retries = 10; + $this->health_check_start_period = 5; + $this->save(); + + return; + } + + if ($hasHealthcheck && ($this->isHealthcheckDisabled() || $isInit)) { $healthcheckCommand = null; $lines = $dockerfile->toArray(); foreach ($lines as $line) { 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/PrivateKey.php b/app/Models/PrivateKey.php index 08f3f1ebd..c5cbc6338 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -82,9 +82,10 @@ public function getPublicKey() public static function ownedByCurrentTeam(array $select = ['*']) { + $teamId = currentTeam()->id; $selectArray = collect($select)->concat(['id']); - return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + return self::whereTeamId($teamId)->select($selectArray->all()); } public static function validatePrivateKey($privateKey) diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 3abd55e9c..6da4dd4c6 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -79,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/User.php b/app/Models/User.php index 9ab9fefe9..f04b6fa77 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -338,6 +338,39 @@ public function role() return data_get($user, 'pivot.role'); } + /** + * Check if the user is an admin or owner of a specific team + */ + public function isAdminOfTeam(int $teamId): bool + { + $team = $this->teams->where('id', $teamId)->first(); + + if (! $team) { + return false; + } + + $role = $team->pivot->role ?? null; + + return $role === 'admin' || $role === 'owner'; + } + + /** + * Check if the user can access system resources (team_id=0) + * Must be admin/owner of root team + */ + public function canAccessSystemResources(): bool + { + // Check if user is member of root team + $rootTeam = $this->teams->where('id', 0)->first(); + + if (! $rootTeam) { + return false; + } + + // Check if user is admin or owner of root team + return $this->isAdminOfTeam(0); + } + public function requestEmailChange(string $newEmail): void { // Generate 6-digit code diff --git a/app/Policies/PrivateKeyPolicy.php b/app/Policies/PrivateKeyPolicy.php index 996054c95..9f3381faf 100644 --- a/app/Policies/PrivateKeyPolicy.php +++ b/app/Policies/PrivateKeyPolicy.php @@ -20,8 +20,18 @@ public function viewAny(User $user): bool */ public function view(User $user, PrivateKey $privateKey): bool { - // return $user->teams->contains('id', $privateKey->team_id); - return true; + // Handle null team_id + if ($privateKey->team_id === null) { + return false; + } + + // System resource (team_id=0): Only root team admins/owners can access + if ($privateKey->team_id === 0) { + return $user->canAccessSystemResources(); + } + + // Regular resource: Check team membership + return $user->teams->contains('id', $privateKey->team_id); } /** @@ -29,8 +39,9 @@ public function view(User $user, PrivateKey $privateKey): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + // Only admins/owners can create private keys + // Members should not be able to create SSH keys that could be used for deployments + return $user->isAdmin(); } /** @@ -38,8 +49,19 @@ public function create(User $user): bool */ public function update(User $user, PrivateKey $privateKey): bool { - // return $user->isAdmin() && $user->teams->contains('id', $privateKey->team_id); - return true; + // Handle null team_id + if ($privateKey->team_id === null) { + return false; + } + + // System resource (team_id=0): Only root team admins/owners can update + if ($privateKey->team_id === 0) { + return $user->canAccessSystemResources(); + } + + // Regular resource: Must be admin/owner of the team + return $user->isAdminOfTeam($privateKey->team_id) + && $user->teams->contains('id', $privateKey->team_id); } /** @@ -47,8 +69,19 @@ public function update(User $user, PrivateKey $privateKey): bool */ public function delete(User $user, PrivateKey $privateKey): bool { - // return $user->isAdmin() && $user->teams->contains('id', $privateKey->team_id); - return true; + // Handle null team_id + if ($privateKey->team_id === null) { + return false; + } + + // System resource (team_id=0): Only root team admins/owners can delete + if ($privateKey->team_id === 0) { + return $user->canAccessSystemResources(); + } + + // Regular resource: Must be admin/owner of the team + return $user->isAdminOfTeam($privateKey->team_id) + && $user->teams->contains('id', $privateKey->team_id); } /** diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php index b7ef48943..849e23751 100644 --- a/app/Policies/TeamPolicy.php +++ b/app/Policies/TeamPolicy.php @@ -42,8 +42,7 @@ public function update(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -56,8 +55,7 @@ public function delete(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -70,8 +68,7 @@ public function manageMembers(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -84,8 +81,7 @@ public function viewAdmin(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -98,7 +94,6 @@ public function manageInvitations(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } } diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index aa6de3897..dd4d6e631 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -88,7 +88,14 @@ public function getImages(): array public function getServerTypes(): array { - return $this->requestPaginated('get', '/server_types', 'server_types'); + $types = $this->requestPaginated('get', '/server_types', 'server_types'); + + // Filter out entries where "deprecated" is explicitly true + $filtered = array_filter($types, function ($type) { + return ! (isset($type['deprecated']) && $type['deprecated'] === true); + }); + + return array_values($filtered); } public function getSshKeys(): array 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/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index 88f858ec9..eb38d84af 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -6,9 +6,14 @@ 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. */ @@ -47,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 33e264e37..3b7a9ee34 100644 --- a/app/View/Components/Forms/Datalist.php +++ b/app/View/Components/Forms/Datalist.php @@ -10,6 +10,10 @@ class Datalist extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -47,11 +51,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.datalist'); diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 83c98c0df..5ed347f42 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -10,6 +10,10 @@ class Input extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + public function __construct( public ?string $id = null, public ?string $name = null, @@ -43,11 +47,26 @@ 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; } 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 49b69136b..026e3ba8c 100644 --- a/app/View/Components/Forms/Select.php +++ b/app/View/Components/Forms/Select.php @@ -10,6 +10,10 @@ class Select extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -40,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 3148d2566..a5303b947 100644 --- a/app/View/Components/Forms/Textarea.php +++ b/app/View/Components/Forms/Textarea.php @@ -10,6 +10,10 @@ class Textarea extends Component { + public ?string $modelBinding = null; + + public ?string $htmlId = null; + /** * Create a new component instance. */ @@ -27,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, @@ -53,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/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 36243e119..382e2d015 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -51,6 +51,8 @@ const SPECIFIC_SERVICES = [ 'quay.io/minio/minio', 'minio/minio', + 'ghcr.io/coollabsio/minio', + 'coollabsio/minio', 'svhd/logto', ]; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index b63c3fc3b..d6c9b5bdf 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -378,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]; } @@ -1120,6 +1130,76 @@ 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 diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 09d4c7549..84d2e03b2 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -16,6 +16,101 @@ use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; +/** + * Validates a Docker Compose YAML string for command injection vulnerabilities. + * This should be called BEFORE saving to database to prevent malicious data from being stored. + * + * @param string $composeYaml The raw Docker Compose YAML content + * + * @throws \Exception If the compose file contains command injection attempts + */ +function validateDockerComposeForInjection(string $composeYaml): void +{ + try { + $parsed = Yaml::parse($composeYaml); + } catch (\Exception $e) { + throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e); + } + + 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); @@ -212,6 +307,46 @@ function parseDockerVolumeString(string $volumeString): array // 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, @@ -265,6 +400,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $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', [])); @@ -561,6 +706,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $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'); @@ -1125,7 +1297,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return array_search($key, $customOrder); }); - $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2); + $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); + $resource->docker_compose = $cleanedCompose; + // Also update docker_compose_raw to remove content: from volumes + // This prevents content from being reapplied on subsequent deployments + $resource->docker_compose_raw = $cleanedCompose; data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); @@ -1178,6 +1354,16 @@ function serviceParser(Service $resource): Collection $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) { @@ -1575,6 +1761,33 @@ function serviceParser(Service $resource): Collection $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'); @@ -2011,7 +2224,11 @@ function serviceParser(Service $resource): Collection return array_search($key, $customOrder); }); - $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2); + $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); + $resource->docker_compose = $cleanedCompose; + // Also update docker_compose_raw to remove content: from volumes + // This prevents content from being reapplied on subsequent deployments + $resource->docker_compose_raw = $cleanedCompose; data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 308f522fb..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); @@ -1285,6 +1327,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); @@ -1294,6 +1342,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"); } } @@ -2005,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); @@ -2014,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"); } } diff --git a/config/constants.php b/config/constants.php index 01eaa7fa1..813594e61 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.435', + 'version' => '4.0.0-beta.438', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), 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/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/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/docker-compose.dev.yml b/docker-compose.dev.yml index fee17dad6..d76c91aa2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -104,7 +104,7 @@ services: networks: - coolify minio: - image: minio/minio:latest + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 pull_policy: always container_name: coolify-minio command: server /data --console-address ":9001" diff --git a/lang/en.json b/lang/en.json index af7f2145d..a81e1ee68 100644 --- a/lang/en.json +++ b/lang/en.json @@ -23,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", 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/openapi.json b/openapi.json index 3667cbe87..dd3c6783a 100644 --- a/openapi.json +++ b/openapi.json @@ -3356,6 +3356,137 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Backup", + "description": "Create a new scheduled backup configuration for a database", + "operationId": "create-database-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Backup configuration data", + "required": true, + "content": { + "application\/json": { + "schema": { + "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" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Backup configuration created successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "message": { + "type": "string", + "example": "Backup configuration created successfully." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/databases\/{uuid}": { @@ -5381,6 +5512,96 @@ ] } }, + "\/deployments\/{uuid}\/cancel": { + "post": { + "tags": [ + "Deployments" + ], + "summary": "Cancel", + "description": "Cancel a deployment by UUID.", + "operationId": "cancel-deployment-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Deployment UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deployment cancelled successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deployment cancelled successfully." + }, + "deployment_uuid": { + "type": "string", + "example": "cm37r6cqj000008jm0veg5tkm" + }, + "status": { + "type": "string", + "example": "cancelled-by-user" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Deployment cannot be cancelled (already finished\/failed\/cancelled).", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deployment cannot be cancelled. Current status: finished" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "User doesn't have permission to cancel this deployment.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You do not have permission to cancel this deployment." + } + }, + "type": "object" + } + } + } + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deploy": { "get": { "tags": [ @@ -5538,6 +5759,91 @@ } }, "\/github-apps": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "List", + "description": "List all GitHub apps.", + "operationId": "list-github-apps", + "responses": { + "200": { + "description": "List of GitHub apps.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "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" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, "post": { "tags": [ "GitHub Apps" diff --git a/openapi.yaml b/openapi.yaml index b7df65567..754b7ec6f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2130,6 +2130,94 @@ paths: security: - bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Backup' + description: 'Create a new scheduled backup configuration for a database' + operationId: create-database-backup + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + requestBody: + description: 'Backup configuration data' + required: true + content: + application/json: + schema: + 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' + type: object + responses: + '201': + description: 'Backup configuration created successfully' + content: + application/json: + schema: + properties: + uuid: { type: string, format: uuid, example: 550e8400-e29b-41d4-a716-446655440000 } + message: { type: string, example: 'Backup configuration created successfully.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/databases/{uuid}': get: tags: @@ -3532,6 +3620,55 @@ paths: security: - bearerAuth: [] + '/deployments/{uuid}/cancel': + post: + tags: + - Deployments + summary: Cancel + description: 'Cancel a deployment by UUID.' + operationId: cancel-deployment-by-uuid + parameters: + - + name: uuid + in: path + description: 'Deployment UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Deployment cancelled successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Deployment cancelled successfully.' } + deployment_uuid: { type: string, example: cm37r6cqj000008jm0veg5tkm } + status: { type: string, example: cancelled-by-user } + type: object + '400': + description: 'Deployment cannot be cancelled (already finished/failed/cancelled).' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Deployment cannot be cancelled. Current status: finished' } + type: object + '401': + $ref: '#/components/responses/401' + '403': + description: "User doesn't have permission to cancel this deployment." + content: + application/json: + schema: + properties: + message: { type: string, example: 'You do not have permission to cancel this deployment.' } + type: object + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deploy: get: tags: @@ -3631,6 +3768,29 @@ paths: - bearerAuth: [] /github-apps: + get: + tags: + - 'GitHub Apps' + summary: List + description: 'List all GitHub apps.' + operationId: list-github-apps + responses: + '200': + description: 'List of GitHub apps.' + content: + application/json: + schema: + type: array + items: + 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 } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] post: tags: - 'GitHub Apps' diff --git a/package-lock.json b/package-lock.json index 210747b4e..ce1097097 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,13 +74,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "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.28.0" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -90,9 +90,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "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.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "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" ], @@ -461,9 +461,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "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" ], @@ -478,9 +478,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], @@ -495,9 +495,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "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" ], @@ -512,9 +512,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], @@ -529,9 +529,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "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" ], @@ -546,9 +546,9 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz", - "integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==", + "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": { @@ -565,9 +565,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "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": { @@ -586,16 +586,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "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.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "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": { @@ -604,9 +604,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "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" ], @@ -618,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "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" ], @@ -632,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "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" ], @@ -646,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "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" ], @@ -660,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "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" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "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" ], @@ -688,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "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" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "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" ], @@ -716,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "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" ], @@ -730,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "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" ], @@ -743,10 +743,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "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" ], @@ -758,9 +758,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "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" ], @@ -772,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "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" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "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" ], @@ -800,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "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" ], @@ -814,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "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" ], @@ -828,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "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" ], @@ -841,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.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "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" ], @@ -856,9 +870,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "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" ], @@ -869,10 +883,24 @@ "win32" ] }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "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" ], @@ -1130,66 +1158,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.10", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.0", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", @@ -1501,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" @@ -1537,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": { @@ -1604,9 +1572,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "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": { @@ -1680,9 +1648,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "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", @@ -1693,32 +1661,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@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": { @@ -1729,11 +1697,14 @@ "license": "MIT" }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "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" }, @@ -1932,9 +1903,9 @@ } }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "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": { @@ -2245,13 +2216,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": { @@ -2307,9 +2278,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": { @@ -2319,22 +2290,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", @@ -2456,9 +2411,9 @@ } }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "dev": true, "license": "MIT", "peer": true, @@ -2488,9 +2443,9 @@ } }, "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "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": { @@ -2504,26 +2459,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@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" } }, @@ -2633,27 +2590,30 @@ "peer": true }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "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": { @@ -2661,14 +2621,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "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" 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/home-assistant.svg b/public/svgs/home-assistant.svg new file mode 100644 index 000000000..7bce628cf --- /dev/null +++ b/public/svgs/home-assistant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/svgs/metamcp.png b/public/svgs/metamcp.png new file mode 100644 index 000000000..e1eeb5c06 Binary files /dev/null and b/public/svgs/metamcp.png differ diff --git a/public/svgs/pocketid-logo.png b/public/svgs/pocketid-logo.png new file mode 100644 index 000000000..8aa7f00f9 Binary files /dev/null and b/public/svgs/pocketid-logo.png differ diff --git a/public/svgs/redisinsight.png b/public/svgs/redisinsight.png new file mode 100644 index 000000000..bc8056276 Binary files /dev/null and b/public/svgs/redisinsight.png differ diff --git a/public/svgs/rivet.svg b/public/svgs/rivet.svg new file mode 100644 index 000000000..342185b4d --- /dev/null +++ b/public/svgs/rivet.svg @@ -0,0 +1 @@ + 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/siyuan.svg b/public/svgs/siyuan.svg new file mode 100644 index 000000000..fc15edd5e --- /dev/null +++ b/public/svgs/siyuan.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/svgs/sparkyfitness.svg b/public/svgs/sparkyfitness.svg new file mode 100644 index 000000000..7f599cef1 --- /dev/null +++ b/public/svgs/sparkyfitness.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 1a95de03a..f819280d5 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -32,7 +32,7 @@ @utility apexcharts-tooltip-custom-title { } @utility input-sticky { - @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; + @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning; } @utility input-sticky-active { @@ -46,20 +46,29 @@ @utility input-focus { /* input, select before */ @utility input-select { - @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent; + @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-2 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent; } /* Readonly */ @utility input { @apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200; @apply input-select; - @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; + @apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning; } @utility select { @apply w-full; @apply input-select; - @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; + @apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1rem 1rem; + padding-right: 2.5rem; + + &:where(.dark, .dark *) { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); + } } @utility button { diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 287f2f170..ce8f21481 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -1,29 +1,51 @@ -
-
-
-
Coolify
- {{-- --}} -
-
-
- @csrf - - {{ __('auth.confirm_password') }} - - @if ($errors->any()) -
- @foreach ($errors->all() as $error) -

{{ $error }}

- @endforeach +
+
+
+
+

+ Coolify +

+

+ Confirm Your Password +

+
+ +
+ @if (session('status')) +
+

{{ session('status') }}

+
+ @endif + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + +
+
+ + + +

+ This is a secure area. Please confirm your password before continuing. +

+
- @endif - @if (session('status')) -
- {{ session('status') }} -
- @endif + +
+ @csrf + + + {{ __('auth.confirm_password') }} + + +
-
+ diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index 66a924fb8..4952cfabd 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -1,42 +1,88 @@
- - Coolify -
- {{ __('auth.forgot_password_heading') }} -
-
-
+
+
+

+ Coolify +

+

+ {{ __('auth.forgot_password_heading') }} +

+
+ +
+ @if (session('status')) +
+
+ + + +

{{ session('status') }}

+
+
+ @endif + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + @if (is_transactional_emails_enabled()) -
- @csrf - - {{ __('auth.forgot_password_send_email') }} - - @else -
Transactional emails are not active on this instance.
-
See how to set it in our docs, or how to - manually reset password. +
+ @csrf + + + {{ __('auth.forgot_password_send_email') }} + + + @else +
+
+ + + +
+

Email Not Configured

+

+ Transactional emails are not active on this instance. +

+

+ See how to set it in our documentation, or + learn how to manually reset your password. +

+
+
+
+ @endif + +
+
+
+
+
+ + Remember your password? + +
- @endif - @if ($errors->any()) -
- @foreach ($errors->all() as $error) -

{{ $error }}

- @endforeach -
- @endif - @if (session('status')) -
- {{ session('status') }} -
- @endif + + + Back to Login +
- -
+ \ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 8bd8e81fc..f85dc268e 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -1,79 +1,102 @@
- - Coolify - -
- @if ($errors->any()) -
- @foreach ($errors->all() as $error) -

{{ $error }}

- @endforeach -
- @endif -
-
- @csrf - @env('local') - +
+
+

+ Coolify +

+
- - - - {{ __('auth.forgot_password_link') }} - - @else - - - - {{ __('auth.forgot_password_link') }} - - @endenv - - {{ __('auth.login') }} - - @if (session('error')) -
- {{ session('error') }} -
- @endif - @if (!$is_registration_enabled) -
{{ __('auth.registration_disabled') }}
- @endif - @if (session('status')) -
- {{ session('status') }} -
- @endif - - @if ($is_registration_enabled) - - {{ __('auth.register_now') }} - - @endif - @if ($enabled_oauth_providers->isNotEmpty()) -
- -
- or -
+
+ @if (session('status')) +
+

{{ session('status') }}

@endif - @foreach ($enabled_oauth_providers as $provider_setting) - - {{ __("auth.login.$provider_setting->provider") }} + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + +
+ @csrf + @env('local') + + + @else + + + @endenv + + + + + {{ __('auth.login') }} - @endforeach + + + @if ($is_registration_enabled) +
+
+
+
+
+ + Don't have an account? + +
+
+ + {{ __('auth.register_now') }} + + @else +
+ {{ __('auth.registration_disabled') }} +
+ @endif + + @if ($enabled_oauth_providers->isNotEmpty()) +
+
+
+
+
+ or + continue with +
+
+
+ @foreach ($enabled_oauth_providers as $provider_setting) + + {{ __("auth.login.$provider_setting->provider") }} + + @endforeach +
+ @endif
-
+ \ No newline at end of file diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index a54233774..3db943726 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -11,22 +11,43 @@ function getOldOrLocal($key, $localValue)
- - Coolify - -
-
-
-

- Create an account -

- @if ($isFirstUser) -
This user will be the root user (full admin access). +
+
+

+ Coolify +

+

+ Create your account +

+
+ +
+ @if ($isFirstUser) +
+
+ + + +
+

Root User Setup

+

This user will be the root user with full admin access.

+
- @endif -
-
+
+ @endif + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + + @csrf @@ -36,15 +57,32 @@ class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl label="{{ __('input.password') }}" /> -
Your password should be min 8 characters long and contain - at least one uppercase letter, one lowercase letter, one number, and one symbol.
-
- Register - - {{ __('auth.already_registered') }} - + +
+

+ Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol. +

+ + + Create Account + + +
+
+
+
+
+ + Already have an account? + +
+
+ + + {{ __('auth.already_registered') }} +
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index ae85b11a5..a4a07ebd6 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -1,39 +1,80 @@
- - Coolify - -
- {{ __('auth.reset_password') }} -
-
-
-
+
+
+

+ Coolify +

+

+ {{ __('auth.reset_password') }} +

+
+ +
+ @if (session('status')) +
+
+ + + +

{{ session('status') }}

+
+
+ @endif + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + +
+

+ Enter your new password below. Make sure it's strong and secure. +

+
+ + @csrf -
- - + + + +
+

+ Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol. +

- {{ __('auth.reset_password') }} + + + {{ __('auth.reset_password') }} + - @if ($errors->any()) -
- @foreach ($errors->all() as $error) -

{{ $error }}

- @endforeach + +
+
+
- @endif - @if (session('status')) -
- {{ session('status') }} +
+ + Remember your password? +
- @endif +
+ + + Back to Login +
diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index 238b7ad8d..d4531cbe8 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -1,40 +1,137 @@ -
+
- - Coolify - -
-
-
- @csrf -
- -
Enter - Recovery Code -
+
+
+

+ Coolify +

+

+ Two-Factor Authentication +

+
+ +
+ @if (session('status')) +
+

{{ session('status') }}

-
- -
- {{ __('auth.login') }} - + @endif + @if ($errors->any()) -
+
@foreach ($errors->all() as $error) -

{{ $error }}

+

{{ $error }}

@endforeach
@endif - @if (session('status')) -
- {{ session('status') }} + +
+
+ + + +

+ Enter the verification code from your authenticator app to continue. +

- @endif +
+ +
+ @csrf +
+ +
+ +
+ +
+
+ + +
+ + {{ __('auth.login') }} + +
+ +
+
+
+
+
+ + Need help? + +
+
+ + + Back to Login +
- + \ No newline at end of file diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 868f657f6..b291759a8 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -32,14 +32,14 @@ merge(['class' => $defaultClass]) }} wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' - wire:model={{ $id }} @if ($checked) checked @endif /> + wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @else @if ($domValue) merge(['class' => $defaultClass]) }} - value={{ $domValue }} @if ($checked) checked @endif /> + value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @else merge(['class' => $defaultClass]) }} - wire:model={{ $value ?? $id }} @if ($checked) checked @endif /> + wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @endif @endif diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 7f9ffefec..5bb12aa8d 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -16,7 +16,7 @@
+ wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"> {{-- Selected Tags Inside Input --}} diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e7e26c134..9ce846d3a 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -16,14 +16,14 @@
General configuration for your application.
- - + +
@if (!$application->dockerfile && $application->build_pack !== 'dockerimage')
- @@ -31,7 +31,7 @@ @if ($application->settings->is_static || $application->build_pack === 'static') - @@ -66,7 +66,7 @@
@endif @if ($application->settings->is_static || $application->build_pack === 'static') - @can('update', $application) @@ -77,25 +77,25 @@ @endif
@if ($application->could_set_build_commands()) - @endif @if ($application->settings->is_static && $application->build_pack !== 'static') @endif
@if ($application->build_pack !== 'dockercompose')
@if ($application->settings->is_container_label_readonly_enabled == false) - @else - @@ -121,7 +121,7 @@ x-bind:disabled="!canUpdate" /> @endif @else - @@ -164,15 +164,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->build_pack === 'dockerimage') @if ($application->destination->server->isSwarm()) - - @else - - @endif @@ -181,18 +181,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" $application->destination->server->isSwarm() || $application->additional_servers->count() > 0 || $application->settings->is_build_server_enabled) - - @else - - @@ -206,20 +206,20 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @else @if ($application->could_set_build_commands()) @if ($application->build_pack === 'nixpacks')
Nixpacks will detect the required configuration @@ -239,16 +239,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endcan
@@ -261,12 +261,12 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@@ -274,36 +274,36 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
@else
- @if ($application->build_pack === 'dockerfile' && !$application->dockerfile) - @endif @if ($application->build_pack === 'dockerfile') - @endif @if ($application->could_set_build_commands()) @if ($application->settings->is_static) - @else - @endif @endif @@ -313,21 +313,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif @if ($application->build_pack !== 'dockercompose')
@endif @@ -344,18 +344,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endcan
@if ($application->settings->is_raw_compose_deployment_enabled) - @else @if ((int) $application->compose_parsing_version >= 3) - @endif - @@ -363,45 +363,45 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
{{-- --}} + id="is_container_label_readonly_enabled" instantSave> --}}
@endif @if ($application->dockerfile) - @endif @if ($application->build_pack !== 'dockercompose')

Network

@if ($application->settings->is_static || $application->build_pack === 'static') - @else @if ($application->settings->is_container_label_readonly_enabled === false) - @else - @endif @endif @if (!$application->destination->server->isSwarm()) - @endif @if (!$application->destination->server->isSwarm()) - + wire:model="custom_network_aliases" x-bind:disabled="!canUpdate" /> @endif
@@ -409,14 +409,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->is_http_basic_auth_enabled)
- -
@endif @@ -432,11 +432,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@can('update', $application) @@ -455,21 +455,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"

Pre/Post Deployment Commands

@if ($application->build_pack === 'dockercompose') - @endif
@if ($application->build_pack === 'dockercompose') @endif
diff --git a/resources/views/livewire/project/application/previews-compose.blade.php b/resources/views/livewire/project/application/previews-compose.blade.php index ffed66814..ae8d70243 100644 --- a/resources/views/livewire/project/application/previews-compose.blade.php +++ b/resources/views/livewire/project/application/previews-compose.blade.php @@ -1,7 +1,7 @@
- + Save Generate Domain -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index c2f634cd7..da75fb704 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -112,7 +112,7 @@ class="dark:text-warning">{{ $application->destination->server->name }}.<
+ id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"> @can('update', $application) Save Generate @@ -130,7 +130,7 @@ class="flex items-end gap-2 pt-4"> @else + id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"> @can('update', $application) Save Generate diff --git a/resources/views/livewire/project/new/docker-compose.blade.php b/resources/views/livewire/project/new/docker-compose.blade.php index 661e11b7e..bdf1cea24 100644 --- a/resources/views/livewire/project/new/docker-compose.blade.php +++ b/resources/views/livewire/project/new/docker-compose.blade.php @@ -7,7 +7,7 @@ Save
- - + + + label="Image" id="image">
- - +
@if ($db_url_public) + id="excludeFromStatus"> + instantSave="instantSaveLogDrain" id="isLogDrainEnabled" label="Drain Logs" />
diff --git a/resources/views/livewire/project/service/edit-compose.blade.php b/resources/views/livewire/project/service/edit-compose.blade.php index df0b857b5..313240849 100644 --- a/resources/views/livewire/project/service/edit-compose.blade.php +++ b/resources/views/livewire/project/service/edit-compose.blade.php @@ -6,24 +6,24 @@
- +
+ id="dockerComposeRaw">
- +
+ id="isContainerLabelEscapeEnabled" instantSave>
diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index 9d30957f0..a126eca5b 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -3,7 +3,7 @@
Note: If a service has a defined port, do not delete it.
If you want to use your custom domain, you can add it with a port.
Save diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index dc8f949fa..4ab966ec3 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -60,12 +60,12 @@ @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
+ id="isBasedOnGit">
@endif @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) Save @@ -74,12 +74,12 @@ @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
+ id="isBasedOnGit">
@endif + rows="20" id="content" disabled> @endcan @endif @else @@ -88,12 +88,12 @@ @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
+ id="isBasedOnGit">
@endif + rows="20" id="content" disabled> @endif @endif diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index 4c8dbe61c..b95dc6540 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -23,48 +23,48 @@
- + id="description">
@if (!$application->serviceType()?->contains(str($application->image)->before(':'))) @if ($application->required_fqdn) @else @endif @endif + label="Image" id="image">

Advanced

@if (str($application->image)->contains('pocketbase')) - @else - @endif - + id="excludeFromStatus"> + instantSave="instantSaveAdvanced" id="isLogDrainEnabled" label="Drain Logs" />
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index fff6524ce..5a8a3e420 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -15,11 +15,11 @@
Configuration
- - + +
-
@if ($fields->count() > 0) diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index ed64ff28e..730353c87 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -2,7 +2,7 @@

Healthchecks

Save - @if (!$resource->health_check_enabled) + @if (!$healthCheckEnabled)
Define how your resource's health should be checked.
- @if ($resource->custom_healthcheck_found) + @if ($customHealthcheckFound)

A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.

@endif
- + - + - - + - +
- - +
- - - - +
diff --git a/resources/views/livewire/project/shared/resource-limits.blade.php b/resources/views/livewire/project/shared/resource-limits.blade.php index 2aa2fd0af..99ff249e9 100644 --- a/resources/views/livewire/project/shared/resource-limits.blade.php +++ b/resources/views/livewire/project/shared/resource-limits.blade.php @@ -9,32 +9,32 @@
+ label="Number of CPUs" id="limitsCpus" /> + label="CPU sets to use" id="limitsCpuset" /> + label="CPU Weight" id="limitsCpuShares" />

Limit Memory

+ label="Soft Memory Limit" id="limitsMemoryReservation" /> + id="limitsMemorySwappiness" />
+ label="Maximum Memory Limit" id="limitsMemory" /> + label="Maximum Swap Limit" id="limitsMemorySwap" />
diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 798a97d94..6881e3b10 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -9,47 +9,47 @@ @if ( $storage->resource_type === 'App\Models\ServiceApplication' || $storage->resource_type === 'App\Models\ServiceDatabase') - @else - @endif @if ($isService || $startedAt) - - @else - - @endif
@else
- - - + + +
@endif @else @can('update', $resource) @if ($isFirst)
- - - + +
@else
- - - + + +
@endif
@@ -67,17 +67,17 @@ @else @if ($isFirst)
- - + -
@else
- - - + + +
@endif @endcan diff --git a/resources/views/livewire/security/private-key/index.blade.php b/resources/views/livewire/security/private-key/index.blade.php index 47cfc9b1e..c51c7a00a 100644 --- a/resources/views/livewire/security/private-key/index.blade.php +++ b/resources/views/livewire/security/private-key/index.blade.php @@ -14,22 +14,41 @@
@forelse ($privateKeys as $key) - -
-
- {{ data_get($key, 'name') }} + @can('view', $key) + {{-- Admin/Owner: Clickable link --}} + +
+
+ {{ data_get($key, 'name') }} +
+
+ {{ $key->description }} + @if (!$key->isInUse()) + Unused + @endif +
-
- {{ $key->description }} - @if (!$key->isInUse()) - Unused - @endif + + @else + {{-- Member: Visible but not clickable --}} +
+
+
+ {{ data_get($key, 'name') }} + View Only +
+
+ {{ $key->description }} + @if (!$key->isInUse()) + Unused + @endif +
-
- + @endcan @empty
No private keys found.
@endforelse diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index 8668cfd34..7d90b5005 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -27,8 +27,8 @@
- - + +
@@ -46,17 +46,17 @@ Hide
- @if (data_get($private_key, 'is_git_related')) + @if ($isGitRelated)
- +
@endif
-
- +
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index f11bd732e..f49e7d0ae 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -14,7 +14,7 @@ submitAction="saveCaCertificate" :actions="[ 'This will overwrite the existing CA certificate at /data/coolify/ssl/coolify-ca.crt with your custom CA certificate.', 'This will regenerate all SSL certificates for databases on this server and it will sign them with your custom CA.', - 'You must manually redeploy all your databases on this server so that they use the new SSL certificates singned with your new CA certificate.', + 'You must manually redeploy all your databases on this server so that they use the new SSL certificates signed with your new CA certificate.', 'Because of caching, you probably also need to redeploy all your resources on this server that are using this CA certificate.', ]" confirmationText="/data/coolify/ssl/coolify-ca.crt" shortConfirmationLabel="CA Certificate Path" @@ -24,7 +24,7 @@ submitAction="regenerateCaCertificate" :actions="[ 'This will generate a new CA certificate at /data/coolify/ssl/coolify-ca.crt and replace the existing one.', 'This will regenerate all SSL certificates for databases on this server and it will sign them with the new CA certificate.', - 'You must manually redeploy all your databases on this server so that they use the new SSL certificates singned with the new CA certificate.', + 'You must manually redeploy all your databases on this server so that they use the new SSL certificates signed with the new CA certificate.', 'Because of caching, you probably also need to redeploy all your resources on this server that are using this CA certificate.', ]" confirmationText="/data/coolify/ssl/coolify-ca.crt" shortConfirmationLabel="CA Certificate Path" diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index f33136e0e..85dfa9f35 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -61,6 +61,7 @@