diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index be880b7e78..904460d342 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -50,12 +50,22 @@ class DeleteTeam ->get() ->each(function (User $member) use ($team): void { $member->teams()->detach($team); + $member->clearStoredTeamIfMatches($team->id); DB::table('sessions')->where('user_id', $member->id)->delete(); }); + // The deleting owner is excluded from the loop above; clear their + // stored team too so the deleted id is not restored on next login. + $user->clearStoredTeamIfMatches($team->id); + $team->delete(); - return $user->teams()->first(); + // Resolve the next active team the same way login does: the user's + // stored choice when still valid, or their sole remaining team. + // Returns null for a multi-team user whose active team was just + // deleted, so refreshSession sends them to the selection screen + // instead of silently dropping them into an arbitrary first team. + return User::query()->find($user->id)?->resolveStoredTeam(); }); Cache::forget("user:{$user->id}:team:{$team->id}"); diff --git a/app/Console/Commands/ScheduledJobDiagnostics.php b/app/Console/Commands/ScheduledJobDiagnostics.php index 77881284cb..61f26265a9 100644 --- a/app/Console/Commands/ScheduledJobDiagnostics.php +++ b/app/Console/Commands/ScheduledJobDiagnostics.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Models\Team; use Illuminate\Console\Command; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; class ScheduledJobDiagnostics extends Command @@ -203,7 +204,6 @@ class ScheduledJobDiagnostics extends Command } $dedupKeys = [ - "sentinel-restart:{$server->id}" => '0 0 * * *', "server-patch-check:{$server->id}" => '0 0 * * 0', "server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *', "server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'), @@ -235,7 +235,7 @@ class ScheduledJobDiagnostics extends Command $this->newLine(); } - private function getServers(?string $serverFilter): \Illuminate\Support\Collection + private function getServers(?string $serverFilter): Collection { $query = Server::with('settings')->where('ip', '!=', '1.2.3.4'); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 14b89887d7..4583600995 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1487,7 +1487,13 @@ class ApplicationsController extends Controller $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); $application->domain_port_overrides = $domainPortOverrides; } - $repository_url_parsed = Url::fromString($request->git_repository); + $gitRepository = $application->git_repository; + $httpsRepository = scpStyleGitUrlToHttps($gitRepository); + if (is_string($httpsRepository)) { + $gitRepository = $httpsRepository; + $application->git_repository = $httpsRepository; + } + $repository_url_parsed = Url::fromString($gitRepository); $git_host = $repository_url_parsed->getHost(); if ($git_host === 'github.com') { $application->source_type = GithubApp::class; @@ -1649,11 +1655,7 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Failed to generate Github App token.'], 400); } - $gitRepository = $request->git_repository; - if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) { - $gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', ''); - } - $gitRepository = str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString(); + $gitRepository = gitRepositorySlug($request->git_repository); // Use direct API call to verify repository access instead of loading all repositories // This is much faster and avoids timeouts for GitHub Apps with many repositories diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index 98b1b39e8c..77bc16c1c2 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -12,7 +12,6 @@ use OpenApi\Attributes as OA; class ServerSentinelController extends Controller { private const ALLOWED_FIELDS = [ - 'is_sentinel_enabled', 'is_metrics_enabled', 'is_sentinel_debug_enabled', 'sentinel_token', @@ -43,7 +42,7 @@ class ServerSentinelController extends Controller { $settings = $server->settings; $payload = [ - 'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled, + 'is_sentinel_enabled' => $server->isSentinelEnabled(), 'is_metrics_enabled' => (bool) $settings->is_metrics_enabled, 'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled, 'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds, @@ -83,7 +82,7 @@ class ServerSentinelController extends Controller description: 'Sentinel settings.', content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), + new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'), @@ -139,7 +138,6 @@ class ServerSentinelController extends Controller required: true, content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'sentinel_token', type: 'string'), @@ -186,7 +184,6 @@ class ServerSentinelController extends Controller $this->authorize('update', $server); $validator = customApiValidator($request->all(), [ - 'is_sentinel_enabled' => 'boolean', 'is_metrics_enabled' => 'boolean', 'is_sentinel_debug_enabled' => 'boolean', 'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'], @@ -224,29 +221,12 @@ class ServerSentinelController extends Controller } $settings = $server->settings; - $enablingSentinel = $request->has('is_sentinel_enabled') - && $request->boolean('is_sentinel_enabled') - && ! $settings->is_sentinel_enabled; - - if ($enablingSentinel && $server->isBuildServer()) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']], - ], 422); - } - foreach (self::ALLOWED_FIELDS as $field) { if ($request->has($field)) { $settings->{$field} = $request->input($field); } } - // Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel). - if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) { - $settings->is_metrics_enabled = false; - $settings->is_sentinel_debug_enabled = false; - } - $settings->save(); auditLog('api.server.sentinel.updated', [ diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 93d27615a7..a21850c9bf 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -24,6 +24,14 @@ class OauthController extends Controller $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); + $team = $user->resolveStoredTeam(); + if (! $team && $user->teams()->count() === 0) { + $team = $user->recreate_personal_team(); + } + if ($team) { + session(['currentTeam' => $user->currentTeam = $team]); + } + return redirect('/'); } catch (\Exception $e) { $this->logCallbackFailure($provider, $e); diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index 0463790eb7..65c92f1349 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\Webhook\Concerns; use App\Models\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Illuminate\Support\Str; trait MatchesManualWebhookApplications { @@ -79,12 +78,8 @@ trait MatchesManualWebhookApplications if (is_array($parts) && isset($parts['scheme'])) { $path = data_get($parts, 'path'); - } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { - $path = Str::after($gitRepository, ':'); - // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo". - // Strip the leading numeric port segment so the path matches the webhook - // payload's owner/repo, consistent with convertGitUrl() in shared.php. - $path = preg_replace('#^\d+/#', '', $path) ?? $path; + } elseif (($scp = parseScpStyleGitUrl($gitRepository)) !== null) { + $path = $scp['path']; } else { $path = $gitRepository; } diff --git a/app/Http/Middleware/DecideWhatToDoWithUser.php b/app/Http/Middleware/DecideWhatToDoWithUser.php index dbf261f4db..6babdb69a0 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -18,9 +18,24 @@ class DecideWhatToDoWithUser } if (auth()?->user()?->currentTeam()) { refreshSession(auth()->user()->currentTeam()); + // A team is already active; the selection screen no longer applies. + if ($request->routeIs('team.select')) { + return redirect()->route('dashboard'); + } } elseif (auth()?->user()?->teams?->count() > 0) { - // User's session team is invalid (e.g., removed from team), switch to first available team - refreshSession(auth()->user()->teams->first()); + // No active team in the session (fresh login or invalidated selection). + // Restore the last active team, or the sole team of a single-team user. + $resolvedTeam = auth()->user()->resolveStoredTeam(); + if ($resolvedTeam) { + refreshSession($resolvedTeam); + } elseif ($request->routeIs('team.select') || $request->routeIs('*livewire.update')) { + // Ambiguous choice: let the user pick a team on the selection screen. + // Livewire's update endpoint must pass through too, otherwise the + // selection action's AJAX call is redirected to HTML and never runs. + return $next($request); + } else { + return redirect()->route('team.select'); + } } if (! auth()->user() || ! isCloud()) { if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 67c222c24d..171d4e6949 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -166,14 +166,6 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue } } - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime); - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - - if ($shouldRestartSentinel) { - CheckAndStartSentinelJob::dispatch($server); - } - // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data if ($sentinelOutOfSync) { @@ -195,7 +187,6 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue ServerPatchCheckJob::dispatch($server); } - // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index af2588ddaf..987b53e7f6 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -202,6 +202,9 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue // Broadcast events to update UI ServerValidated::dispatch($this->server->team_id, $this->server->uuid); ServerReachabilityChanged::dispatch($this->server); + if ($this->server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($this->server); + } } catch (\Throwable $e) { Log::error('ValidateAndInstallServer: Exception occurred', [ diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index 226d2e3329..f54f40ffd0 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -33,7 +33,7 @@ class Index extends Component if (session('impersonating')) { session()->forget('impersonating'); $user = User::find(0); - $team_to_switch_to = $user->teams->first(); + $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first(); Auth::login($user); refreshSession($team_to_switch_to); @@ -69,7 +69,7 @@ class Index extends Component if (! $user) { abort(404); } - $team_to_switch_to = $user->teams->first(); + $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first(); Auth::login($user); refreshSession($team_to_switch_to); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 502a69bec4..98c5395bfd 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -216,6 +216,14 @@ class GithubPrivateRepositoryDeployKey extends Component throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); } + if (($scp = parseScpStyleGitUrl($this->repository_url)) !== null) { + $this->git_host = $scp['host']; + $this->git_repository = $this->repository_url; + $this->git_source = 'other'; + + return; + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 81d65bc857..a031c50c00 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -137,10 +137,9 @@ class PublicGitRepository extends Component throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); } - if (str($this->repository_url)->startsWith('git@')) { - $github_instance = str($this->repository_url)->after('git@')->before(':'); - $repository = str($this->repository_url)->after(':')->before('.git'); - $this->repository_url = 'https://'.str($github_instance).'/'.$repository; + $httpsRepositoryUrl = scpStyleGitUrlToHttps($this->repository_url); + if (is_string($httpsRepositoryUrl)) { + $this->repository_url = $httpsRepositoryUrl; } if ( (str($this->repository_url)->startsWith('https://') || diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index aa26071020..e8202b8547 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -151,13 +151,18 @@ class ExecuteContainerCommand extends Component }); if ($this->containers->count() === 1) { - $this->selected_container = data_get($this->containers->first(), 'container.Names'); + $this->selected_container = $this->containerTarget($this->containers->first()); $this->connectToContainer(); } $this->containersLoaded = true; } + private function containerTarget(array $container): string + { + return data_get($container, 'server.uuid').':'.data_get($container, 'container.Names'); + } + public function updatedSelectedContainer() { if ($this->selected_container !== 'default') { @@ -202,12 +207,12 @@ class ExecuteContainerCommand extends Component try { $this->authorize('canAccessTerminal'); // Validate container name format - if (! ValidationPatterns::isValidContainerName($this->selected_container)) { + if (! ValidationPatterns::isValidContainerName(str($this->selected_container)->after(':')->value())) { throw new \InvalidArgumentException('Invalid container name format'); } // Verify container exists in our allowed list - $container = collect($this->containers)->firstWhere('container.Names', $this->selected_container); + $container = $this->containers->first(fn ($candidate) => $this->containerTarget($candidate) === $this->selected_container); if (is_null($container)) { throw new \RuntimeException('Container not found.'); } diff --git a/app/Livewire/SelectTeam.php b/app/Livewire/SelectTeam.php new file mode 100644 index 0000000000..d0a9328562 --- /dev/null +++ b/app/Livewire/SelectTeam.php @@ -0,0 +1,50 @@ +user(); + + // A team is already active, or the user has at most one team: nothing to pick. + if ($user->currentTeam() || $user->teams->count() <= 1) { + $resolved = $user->resolveStoredTeam(); + if ($resolved) { + refreshSession($resolved); + } + + return redirect()->route('dashboard'); + } + } + + public function selectTeam(int $teamId) + { + $user = auth()->user(); + if (! $user->teams->contains('id', $teamId)) { + return; + } + $team = Team::find($teamId); + if (! $team) { + return; + } + refreshSession($team); + + return redirect()->route('dashboard'); + } + + public function render(): View + { + return view('livewire.select-team', [ + 'teams' => auth()->user()->teams, + ])->layout('layouts.simple'); + } +} diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index 52010a91c4..b6444e573d 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -2,8 +2,6 @@ namespace App\Livewire\Server; -use App\Actions\Server\StartSentinel; -use App\Actions\Server\StopSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; @@ -25,8 +23,6 @@ class Sentinel extends Component #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; - public bool $isSentinelEnabled; - public bool $isSentinelDebugEnabled; public ?string $sentinelCustomDockerImage = null; @@ -52,14 +48,12 @@ class Sentinel extends Component $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; - $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; $this->server->settings->save(); } else { $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; } @@ -88,33 +82,6 @@ class Sentinel extends Component } } - public function toggleSentinel(): void - { - try { - $this->authorize('manageSentinel', $this->server); - if (! $this->isSentinelEnabled) { - if ($this->server->isBuildServer()) { - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - $customImage = isDev() ? $this->sentinelCustomDockerImage : null; - StartSentinel::run($this->server, true, null, $customImage); - $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = true; - } else { - $this->isSentinelEnabled = false; - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); - $this->dispatch('refreshServerShow'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function regenerateSentinelToken() { try { diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 49739ac6dd..1190cd59a1 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server\Sentinel; -use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; @@ -30,35 +29,6 @@ class Logs extends Component $this->authorize('viewSentinel', $this->server); } - public function enableSentinel(): void - { - $this->authorize('manageSentinel', $this->server); - - try { - $this->server->refresh(); - if ($this->server->isBuildServer()) { - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - if ($this->server->isSwarm()) { - $this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.'); - - return; - } - if ($this->server->isSentinelEnabled()) { - return; - } - - StartSentinel::run($this->server, true); - $this->server->refresh(); - $this->dispatch('refreshServerShow'); - $this->dispatch('success', 'Sentinel has been enabled.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function render(): View { return view('livewire.server.sentinel.logs'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 38bbe24e7c..b58050cef8 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; @@ -67,8 +66,6 @@ class Show extends Component public ?string $sentinelCustomUrl = null; - public bool $isSentinelEnabled; - public bool $isSentinelDebugEnabled; public ?string $sentinelCustomDockerImage = null; @@ -161,7 +158,6 @@ class Show extends Component 'sentinelMetricsHistoryDays' => 'required|integer|min:1', 'sentinelPushIntervalSeconds' => 'required|integer|min:10', 'sentinelCustomUrl' => 'nullable|url', - 'isSentinelEnabled' => 'required', 'isSentinelDebugEnabled' => 'required', 'serverTimezone' => 'required', ]; @@ -265,7 +261,6 @@ class Show extends Component $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; - $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; if (! validate_timezone($this->serverTimezone)) { @@ -296,7 +291,6 @@ class Show extends Component $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->serverTimezone = $this->server->settings->server_timezone; @@ -425,10 +419,10 @@ class Show extends Component return; } - if ($value === true && $this->isSentinelEnabled) { - $this->isSentinelEnabled = false; + if ($value === true && $this->server->isSentinelEnabled()) { $this->isMetricsEnabled = false; $this->isSentinelDebugEnabled = false; + $this->server->settings->is_sentinel_enabled = false; StopSentinel::dispatch($this->server); $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); } @@ -440,30 +434,6 @@ class Show extends Component } } - public function updatedIsSentinelEnabled($value) - { - try { - $this->authorize('manageSentinel', $this->server); - if ($value === true) { - if ($this->isBuildServer) { - $this->isSentinelEnabled = false; - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - $customImage = isDev() ? $this->sentinelCustomDockerImage : null; - StartSentinel::run($this->server, true, null, $customImage); - } else { - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function regenerateSentinelToken() { try { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index db62bff2db..33b77418d6 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Events\ServerValidated; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -275,6 +276,9 @@ class ValidateAndInstall extends Component $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); ServerValidated::dispatch($this->server->team_id, $this->server->uuid); + if ($this->server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($this->server); + } $this->dispatch('success', 'Server validated, proxy is starting in a moment.'); $proxyShouldRun = CheckProxy::run($this->server, true); if (! $proxyShouldRun) { diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index d99fd2eb1b..f28087056f 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -92,6 +92,7 @@ class Member extends Component DB::transaction(function () use ($teamId): void { $this->member->teams()->detach($teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + $this->member->clearStoredTeamIfMatches($teamId); }); auditLog('ui.team_member.removed', [ 'team_id' => $teamId, diff --git a/app/Models/Application.php b/app/Models/Application.php index 6737e57986..212e99ff03 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -665,15 +665,13 @@ class Application extends BaseModel return "{$this->source->html_url}/{$this->git_repository}/tree/{$this->git_branch}{$base_dir}"; } - // Convert the SSH URL to HTTPS URL - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - + $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + if (is_string($httpsRepository)) { if (str($this->git_repository)->contains('bitbucket')) { - return "https://{$git_repository}/src/{$this->git_branch}{$base_dir}"; + return "{$httpsRepository}/src/{$this->git_branch}{$base_dir}"; } - return "https://{$git_repository}/tree/{$this->git_branch}{$base_dir}"; + return "{$httpsRepository}/tree/{$this->git_branch}{$base_dir}"; } return $this->git_repository; @@ -688,11 +686,9 @@ class Application extends BaseModel if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/settings/hooks"; } - // Convert the SSH URL to HTTPS URL - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - - return "https://{$git_repository}/settings/hooks"; + $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + if (is_string($httpsRepository)) { + return "{$httpsRepository}/settings/hooks"; } return $this->git_repository; @@ -707,11 +703,9 @@ class Application extends BaseModel if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}"; } - // Convert the SSH URL to HTTPS URL - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - - return "https://{$git_repository}/commits/{$this->git_branch}"; + $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + if (is_string($httpsRepository)) { + return "{$httpsRepository}/commits/{$this->git_branch}"; } return $this->git_repository; @@ -730,8 +724,9 @@ class Application extends BaseModel } $git_repository = $this->git_repository; - if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = preg_replace('/^git@([^:]+):/', 'https://$1/', $git_repository); + $httpsRepository = scpStyleGitUrlToHttps($git_repository); + if (is_string($httpsRepository)) { + $git_repository = $httpsRepository; } elseif (str($this->git_repository)->startsWith('ssh://')) { $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } @@ -748,6 +743,17 @@ class Application extends BaseModel return $url->__toString(); } + private function httpsUrlFromScpStyleGitRepository(): ?string + { + $httpsRepository = scpStyleGitUrlToHttps($this->git_repository); + + if (! is_string($httpsRepository)) { + return null; + } + + return Str::replaceEnd('.git', '', $httpsRepository); + } + public function dockerfileLocation(): Attribute { return Attribute::make( @@ -1479,7 +1485,7 @@ class Application extends BaseModel // Check if .gitmodules file exists before running submodule commands $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then"; if ($public) { - $git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&"; + $git_clone_command = "{$git_clone_command} sed -i \"s#[A-Za-z0-9._-]*@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&"; } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; diff --git a/app/Models/Server.php b/app/Models/Server.php index 3bf3ae63bc..15d790d5c9 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -973,12 +973,15 @@ $siteAddress { return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck())); } - public function isSentinelEnabled() + public function isSentinelEnabled(): bool { - return ($this->isMetricsEnabled() || $this->isServerApiEnabled()) && ! $this->isBuildServer(); + return ! $this->isBuildServer() + && ! $this->isSwarm() + && ! $this->isForceDisabled() + && ! $this->isTransferredAway(); } - public function isMetricsEnabled() + public function isMetricsEnabled(): bool { return $this->settings->is_metrics_enabled; } @@ -988,7 +991,7 @@ $siteAddress { return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); } - public function isServerApiEnabled() + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; } diff --git a/app/Models/User.php b/app/Models/User.php index 10303422bd..9f037bb917 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,6 +49,7 @@ class User extends Authenticatable implements SendsEmail 'name', 'email', 'password', + 'current_team_id', 'force_password_reset', 'marketing_emails', 'pending_email', @@ -67,6 +68,7 @@ class User extends Authenticatable implements SendsEmail ]; protected $casts = [ + 'current_team_id' => 'integer', 'email_verified_at' => 'datetime', 'force_password_reset' => 'boolean', 'show_boarding' => 'boolean', @@ -375,6 +377,54 @@ class User extends Authenticatable implements SendsEmail }); } + /** + * Resolve the team to activate when the session has no current team + * (fresh login or an invalidated session). + * + * Returns the user's last active team when they still belong to it, or the + * sole team of a single-team user. Returns null when the choice is ambiguous + * (more than one team and no valid stored preference) — the caller must then + * prompt the user to pick a team instead of defaulting silently. + */ + public function resolveStoredTeam(): ?Team + { + if (! is_null($this->current_team_id)) { + $storedTeam = $this->teams->firstWhere('id', $this->current_team_id); + if ($storedTeam) { + return $storedTeam; + } + } + + if ($this->teams->count() === 1) { + return $this->teams->first(); + } + + return null; + } + + /** + * Reset the persisted active team when it points to the given team. + * + * Called when the user is removed from a team (or the team is deleted) so a + * stale current_team_id can never be trusted after the fact. Read paths + * already re-validate membership; this is defense-in-depth that clears the + * dangling value at the source event instead of relying on self-healing. + */ + public function clearStoredTeamIfMatches(int $teamId): void + { + // Atomic conditional update: only null the column when the database value + // still points at this team, so a newer team selection made concurrently + // (in another request) is preserved rather than clobbered. + static::query() + ->whereKey($this->getKey()) + ->where('current_team_id', $teamId) + ->update(['current_team_id' => null]); + + if ($this->current_team_id === $teamId) { + $this->current_team_id = null; + } + } + public function role(): ?string { if (data_get($this, 'pivot')) { diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index b5ca1922eb..6426860187 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -90,14 +90,19 @@ class FortifyServiceProvider extends ServiceProvider } $user->currentTeam = $invitation->team; $invitation->delete(); + session(['currentTeam' => $user->currentTeam]); } else { - // Normal login - use personal team - $user->currentTeam = $user->teams->firstWhere('personal_team', true); - if (! $user->currentTeam) { - $user->currentTeam = $user->recreate_personal_team(); + // Restore the last active team; only fall back when unambiguous. + $team = $user->resolveStoredTeam(); + if (! $team && $user->teams->isEmpty()) { + $team = $user->recreate_personal_team(); } + if ($team) { + session(['currentTeam' => $user->currentTeam = $team]); + } + // Otherwise (multiple teams, no stored choice) leave the session + // team unset so the user is sent to the team-selection screen. } - session(['currentTeam' => $user->currentTeam]); return $user; } diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index ba1aed11b6..29e219bd35 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -77,15 +77,16 @@ class ValidGitRepositoryUrl implements ValidationRule } // Validate based on URL type - if (str_starts_with($value, 'git@')) { + if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); return; } - // Validate SSH URL format (git@host:user/repo.git) - if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) { + // Validate scp-style SSH URL format (user@host:user/repo.git) + $scp = parseScpStyleGitUrl($value); + if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { $fail('The :attribute is not a valid SSH repository URL.'); return; @@ -149,7 +150,7 @@ class ValidGitRepositoryUrl implements ValidationRule return; } } else { - $fail('The :attribute must start with https://, http://, git://, or git@.'); + $fail('The :attribute must start with https://, http://, git://, or be an SSH URL (user@host:path).'); return; } diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php index d1d984a191..76cb4a5e8d 100644 --- a/app/Services/ServerTransfer/ServerTransferClaimer.php +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -54,7 +54,7 @@ class ServerTransferClaimer if ($rebindSentinel && $server->settings) { $server->settings->sentinel_custom_url = $instanceUrl; $server->settings->ensureValidSentinelToken(); - // Leave sentinel disabled until operator enables metrics; endpoint is ready. + $server->settings->is_sentinel_enabled = true; $server->settings->save(); $sentinelRebound = true; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 008288adcb..7e10ded579 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -570,8 +570,11 @@ function refreshSession(?Team $team = null): void $team = Team::find($currentTeam->id); } if (! $team) { - // Fall back to any team the user still belongs to. - $team = User::query()->find(Auth::id())?->teams()->first(); + // Fall back to the user's resolvable team (stored choice, or their + // sole team). Returns null for a multi-team user with no valid stored + // choice, so an arbitrary first team is never silently persisted — + // the user is sent to the selection screen instead. + $team = User::query()->find(Auth::id())?->resolveStoredTeam(); } } @@ -581,8 +584,13 @@ function refreshSession(?Team $team = null): void if (! $team) { // The user has no team left (e.g. just deleted their current team and // belongs to no other): clear the stale session reference instead of - // dereferencing null. + // dereferencing null, and drop the persisted choice so it is not + // restored on next login. session()->forget('currentTeam'); + $user = Auth::user(); + if ($user && ! is_null($user->current_team_id)) { + $user->forceFill(['current_team_id' => null])->saveQuietly(); + } return; } @@ -593,6 +601,15 @@ function refreshSession(?Team $team = null): void return $team; }); session(['currentTeam' => $team]); + + // Persist the active team so it can be restored after logout/login — but + // never while an admin is impersonating, so viewing another user's account + // does not overwrite that user's real last-active team. + $user = Auth::user(); + if ($user && ! session('impersonating') && $user->current_team_id !== $team->id) { + $user->current_team_id = $team->id; + $user->saveQuietly(); + } } function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null) { @@ -4341,6 +4358,62 @@ NGINX; } } +/** + * Parse an scp-style SSH Git URL (`user@host:path` or `user@host:port/path`). + * + * @return array{user: string, host: string, port: ?string, path: string}|null + */ +function parseScpStyleGitUrl(?string $gitRepository): ?array +{ + if (! is_string($gitRepository) || $gitRepository === '') { + return null; + } + + if (preg_match('/^(?[A-Za-z0-9._-]+)@(?[^:]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { + return null; + } + + $host = trim($matches['host']); + $path = ltrim($matches['path'], '/'); + + if ($host === '' || $path === '') { + return null; + } + + return [ + 'user' => $matches['user'], + 'host' => $host, + 'port' => ($matches['port'] ?? '') === '' ? null : $matches['port'], + 'path' => $path, + ]; +} + +function scpStyleGitUrlToHttps(?string $gitRepository): ?string +{ + $parts = parseScpStyleGitUrl($gitRepository); + + if ($parts === null) { + return null; + } + + return 'https://'.$parts['host'].'/'.$parts['path']; +} + +function gitRepositorySlug(?string $gitRepository): string +{ + if (! is_string($gitRepository) || $gitRepository === '') { + return ''; + } + + if (($scp = parseScpStyleGitUrl($gitRepository)) !== null) { + $gitRepository = $scp['path']; + } elseif (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) { + $gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', ''); + } + + return str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString(); +} + function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array { $repository = $gitRepository; @@ -4351,7 +4424,6 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| 'repository' => $gitRepository, ]; $sshMatches = []; - $matches = []; // Let's try and parse the string to detect if it's a valid SSH string or not preg_match('/((.*?)\:\/\/)?(.*@.*:.*)/', $gitRepository, $sshMatches); @@ -4386,11 +4458,11 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| $providerInfo['port'] = (string) $parsedRepository['port']; } } else { - preg_match('/^(?[^:]+):(?\d+)\/(?.+)$/', $normalizedRepository, $matches); + $scp = parseScpStyleGitUrl($normalizedRepository); - if (! empty($matches['port'])) { - $providerInfo['port'] = $matches['port']; - $repository = "{$matches['host']}:{$matches['path']}"; + if ($scp !== null && $scp['port'] !== null) { + $providerInfo['port'] = $scp['port']; + $repository = "{$scp['user']}@{$scp['host']}:{$scp['path']}"; } } diff --git a/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php b/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php new file mode 100644 index 0000000000..01f53d4221 --- /dev/null +++ b/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('current_team_id')->nullable()->after('id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('current_team_id'); + }); + } +}; diff --git a/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php b/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php new file mode 100644 index 0000000000..3c557fe446 --- /dev/null +++ b/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php @@ -0,0 +1,31 @@ +where('is_sentinel_enabled', false) + ->where('is_build_server', false) + ->where('is_swarm_manager', false) + ->where('is_swarm_worker', false) + ->where('force_disabled', false) + ->where('is_reachable', true) + ->where('is_usable', true) + ->update(['is_sentinel_enabled' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Existing values cannot be distinguished from values enabled before this migration. + } +}; diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php index ebae97078b..fd1f8fb09e 100644 --- a/database/seeders/SentinelSeeder.php +++ b/database/seeders/SentinelSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\Server; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Log; @@ -13,6 +14,10 @@ class SentinelSeeder extends Seeder Server::chunk(100, function ($servers) { foreach ($servers as $server) { try { + if ($server->isSentinelEnabled()) { + $server->settings->is_sentinel_enabled = true; + $server->settings->saveQuietly(); + } if (str($server->settings->sentinel_token)->isEmpty()) { $server->settings->generateSentinelToken(ignoreEvent: true); } @@ -25,11 +30,10 @@ class SentinelSeeder extends Seeder } if (str($server->settings->sentinel_custom_url)->isEmpty()) { - $url = $server->settings->generateSentinelUrl(ignoreEvent: true); - if (str($url)->isEmpty()) { - $server->settings->is_sentinel_enabled = false; - $server->settings->save(); - } + $server->settings->generateSentinelUrl(ignoreEvent: true); + } + if ($server->isFunctional() && $server->isSentinelEnabled() && filled($server->settings->sentinel_custom_url)) { + CheckAndStartSentinelJob::dispatch($server); } } catch (\Throwable $e) { Log::error('Error seeding sentinel: '.$e->getMessage()); diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index c9cb305786..604513b269 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -244,60 +244,28 @@ ]; @endphp -@php - $activeMenuLabel = collect($groupedMenuItems)->flatMap(fn ($items) => $items)->firstWhere('active', true)['label'] ?? 'Settings'; -@endphp