diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index f24b9699f2..c69863c572 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -81,7 +81,7 @@ class CreateNewUser implements CreatesNewUsers } } // Set session variable - session(['currentTeam' => $team]); + session(['currentTeam' => $user->currentTeam = $team]); return $user; } diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index f886bef8c8..76e36fe873 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -305,7 +305,7 @@ class ServersController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first(); + $server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first(); if (is_null($server)) { return response()->json(['message' => 'Server not found.'], 404); } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 62199ba6ec..27138135ef 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -17,6 +17,7 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Visus\Cuid2\Cuid2; class Gitlab extends Controller { @@ -184,7 +185,7 @@ class Gitlab extends Controller continue; } - $deployment_uuid = new_public_id(); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -202,7 +203,7 @@ class Gitlab extends Controller 'mode' => 'app', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid, + 'deployment_uuid' => $deployment_uuid->toString(), 'commit' => $commit, ]); @@ -274,7 +275,7 @@ class Gitlab extends Controller continue; } - $deployment_uuid = new_public_id(); + $deployment_uuid = new Cuid2; $found = ApplicationPreview::where('application_id', $application->id) ->where('pull_request_id', $pull_request_id) ->first(); diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index a12feccd34..350d5359c1 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -28,7 +28,8 @@ class TrustHosts extends Middleware } // Skip host validation if no FQDN is configured (initial setup) - if ($this->configuredFqdnHost() === null) { + $fqdnHost = Cache::get('instance_settings_fqdn_host'); + if ($fqdnHost === '' || $fqdnHost === null) { return $next($request); } @@ -45,7 +46,27 @@ class TrustHosts extends Middleware { $trustedHosts = []; - $fqdnHost = $this->configuredFqdnHost(); + // 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; @@ -76,31 +97,4 @@ class TrustHosts extends Middleware return array_filter($trustedHosts); } - - /** - * Resolve the configured instance FQDN host, cached to avoid a DB query on every request. - * - * An empty string is cached as the "no FQDN" sentinel so negative results are cached too. - */ - private function configuredFqdnHost(): ?string - { - $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 ''; - }); - - return $fqdnHost !== '' ? $fqdnHost : null; - } } diff --git a/app/Jobs/PullTemplatesFromCDN.php b/app/Jobs/PullTemplatesFromCDN.php index ec09285e1e..e8c655449b 100644 --- a/app/Jobs/PullTemplatesFromCDN.php +++ b/app/Jobs/PullTemplatesFromCDN.php @@ -34,11 +34,7 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue ->get(config('constants.services.official')); if ($response->successful()) { // Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled. - if (! store_service_templates_bundle($response->body())) { - Log::error('PullTemplatesFromCDN received an invalid bundle', [ - 'body' => str($response->body())->limit(500)->toString(), - ]); - } + store_service_templates_bundle($response->body()); } else { Log::error('PullTemplatesFromCDN failed', [ 'status' => $response->status(), diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a69424cffd..9b1ef42ce2 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,19 +2,15 @@ namespace App\Livewire\Profile; -use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; -use Livewire\WithFileUploads; class Index extends Component { - use WithFileUploads; - public int $userId; public string $email; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 694f18c9c9..e47d3818fe 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -17,7 +17,6 @@ use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Validation\ValidationException; use Livewire\Attributes\Computed; use Livewire\Component; @@ -189,6 +188,7 @@ class Show extends Component } private function syncData(bool $toModel = false): void + { if ($toModel) { $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); @@ -330,8 +330,6 @@ class Show extends Component if ($this->is_required && $this->resource instanceof Service) { event(new ApplicationConfigurationChanged($this->resource->team()->id)); } - } catch (ValidationException $e) { - throw $e; } catch (\Exception $e) { return handleError($e); } diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index ca98698450..7a530fe297 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -15,13 +15,7 @@ class ApiTokens extends Component public ?string $description = null; - /** - * Untyped on purpose: Livewire skips hydrating null into typed properties, which - * would silently revert a "Never" (null) choice back to the default on the next request. - * - * @var int|string|null - */ - public $expiresInDays = 30; + public ?int $expiresInDays = 30; public $tokens = []; @@ -149,7 +143,7 @@ class ApiTokens extends Component throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); } - $expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null; + $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $token = auth()->user()->createToken($this->description, $permissions, $expiresAt); auditLog('ui.api_token.created', [ 'team_id' => currentTeam()->id, diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 1cfcc56634..d05532776e 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -296,9 +296,7 @@ class ByDigitalOcean extends Component $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; } - $prefix = "{$providerName} API error: "; - - return str_starts_with($details, $prefix) ? $details : $prefix.$details; + return "{$providerName} API error: {$details}"; } public function getAvailableSizesProperty(): array diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 321d8f6588..3a6ce532df 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -375,9 +375,7 @@ class ByVultr extends Component $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; } - $prefix = "{$providerName} API error: "; - - return str_starts_with($details, $prefix) ? $details : $prefix.$details; + return "{$providerName} API error: {$details}"; } private function createVultrServer(VultrService $vultrService): array diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 320285970d..975ce9a241 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -163,7 +163,6 @@ class SettingsEmail extends Component public function toggleSmtp() { try { - $this->authorize('update', $this->settings); $this->resetErrorBag(); if ($this->smtpEnabled) { @@ -186,7 +185,6 @@ class SettingsEmail extends Component public function toggleResend() { try { - $this->authorize('update', $this->settings); $this->resetErrorBag(); if ($this->resendEnabled) { diff --git a/app/Livewire/Team/DangerZone.php b/app/Livewire/Team/DangerZone.php index 643477e24d..74cf3d7ef8 100644 --- a/app/Livewire/Team/DangerZone.php +++ b/app/Livewire/Team/DangerZone.php @@ -16,7 +16,6 @@ class DangerZone extends Component public function mount(): void { $this->team = currentTeam(); - $this->authorize('delete', $this->team); } public function delete(): mixed diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 50df845f7b..6426860187 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -27,7 +27,7 @@ class FortifyServiceProvider extends ServiceProvider public function toResponse($request) { // First user (root) will be redirected to /settings instead of / on registration. - if ($request->user()->currentTeam()?->id === 0) { + if ($request->user()->currentTeam->id === 0) { return redirect()->route('settings.index'); } @@ -88,9 +88,9 @@ class FortifyServiceProvider extends ServiceProvider if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) { $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); } - $team = $invitation->team; + $user->currentTeam = $invitation->team; $invitation->delete(); - session(['currentTeam' => $team]); + session(['currentTeam' => $user->currentTeam]); } else { // Restore the last active team; only fall back when unambiguous. $team = $user->resolveStoredTeam(); @@ -98,7 +98,7 @@ class FortifyServiceProvider extends ServiceProvider $team = $user->recreate_personal_team(); } if ($team) { - session(['currentTeam' => $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. diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index 5915b0cd8a..761b6a8ccb 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -29,7 +29,7 @@ class OauthLoginService : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); - session(['currentTeam' => $team]); + session(['currentTeam' => $user->currentTeam = $team]); if ($this->requiresTwoFactorChallenge($user)) { Auth::logout(); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 688db3f0a4..ace4888338 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -39,9 +39,7 @@ class ConfigurationDiffer $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; - // Static settings missing from an older snapshot were newly tracked, not changed. - // Without any previous snapshot (legacy fallback), every setting is reported. - if ($previous === null && $previousItems !== [] && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) { + if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) { continue; } diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index a788ae823e..8b3e50b7bd 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -15,10 +15,7 @@ trait HasSecretManager public static function bootHasSecretManager(): void { - // Return nothing: a non-null result halts the "deleting" event and skips later listeners. - static::deleting(function ($resource): void { - $resource->secretManagerLink()->delete(); - }); + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); } public function secretManagerLink(): MorphOne diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 7b094e268d..b12a1e9a38 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1484,12 +1484,6 @@ function service_templates_path(): string */ function store_service_templates_bundle(string $json, ?string $fetchedAt = null): bool { - // A 200 response can still be an empty body or an error page; keep the current templates then. - $templates = json_decode($json, true); - if (! is_array($templates) || $templates === [] || array_is_list($templates)) { - return false; - } - $fetchedAt ??= now()->toIso8601String(); $path = service_templates_path(); @@ -1536,9 +1530,7 @@ function get_service_templates(bool $force = false): Collection if ($response->failed()) { return collect([]); } - if (! store_service_templates_bundle($response->body())) { - return get_service_templates(); - } + store_service_templates_bundle($response->body()); return collect(json_decode($response->body()))->sortKeys(); } catch (Throwable) { diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php index be3ea573e1..3b6db03c3a 100644 --- a/resources/views/components/team/settings-layout.blade.php +++ b/resources/views/components/team/settings-layout.blade.php @@ -24,13 +24,13 @@ 'active' => request()->routeIs('team.admin-view'), 'icon' => 'admin', ] : null, - auth()->user()->can('delete', currentTeam()) ? [ + [ 'label' => 'Danger Zone', 'route' => 'team.danger-zone', 'active' => request()->routeIs('team.danger-zone'), 'icon' => 'shield-alert', 'sectionStart' => true, - ] : null, + ], ])->filter(); @endphp diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 01cc64891c..41aedc45ae 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -85,9 +85,7 @@ $activeServerMenuItem['route'], $serverRouteParameters, ); - $showSentinelStatus = $server->isFunctional() - && $server->isSentinelEnabled() - && auth()->user()?->can('viewSentinel', $server); + $showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled(); $proxyCanBeStopped = in_array($proxyStatus, ['running', 'starting', 'restarting'], true); @endphp diff --git a/resources/views/livewire/server/partials/localhost-general.blade.php b/resources/views/livewire/server/partials/localhost-general.blade.php index 3876b4edbe..fef1609998 100644 --- a/resources/views/livewire/server/partials/localhost-general.blade.php +++ b/resources/views/livewire/server/partials/localhost-general.blade.php @@ -13,13 +13,6 @@ :type="$server->isFunctional() ? 'success' : 'warning'" /> - @if ($this->limaStartCommand) - - {{ $this->limaStartCommand }} - - @endif -
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 6ae3c02aa8..4edbf479a3 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -88,24 +88,6 @@ @endif - @if ($server->cloudProviderToken) - @if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off') - - Power On - - @elseif ($provider === 'DigitalOcean' && $digitalOceanDropletStatus === 'off') - - Power On - - @elseif ($provider === 'Vultr' && $vultrInstanceStatus === 'stopped') - - Power On - - @endif - @endif @endif @if ($server->server_metadata) - @if ($isConnected) - - Test connection - - @endif - + Sync name @@ -196,7 +191,7 @@ shortConfirmationLabel="GitHub App Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" /> @else - + Delete @endcan diff --git a/tests/Feature/Authorization/TeamAuthorizationTest.php b/tests/Feature/Authorization/TeamAuthorizationTest.php index 7d437c823f..0637a48db5 100644 --- a/tests/Feature/Authorization/TeamAuthorizationTest.php +++ b/tests/Feature/Authorization/TeamAuthorizationTest.php @@ -171,34 +171,17 @@ test('team index mounts when is_mcp_server_enabled is null on the session team', // --- Team Danger Zone Livewire: delete --- -test('member cannot open the team danger zone', function () { +test('member cannot delete team via danger zone', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); - Livewire::test(DangerZone::class)->assertForbidden(); + Livewire::test(DangerZone::class) + ->call('delete') + ->assertDispatched('error'); expect(Team::find($this->team->id))->not->toBeNull(); }); -test('owner can open the team danger zone', function () { - $this->actingAs($this->owner); - session(['currentTeam' => $this->team]); - - Livewire::test(DangerZone::class)->assertSuccessful(); -}); - -test('team settings show the danger zone link only to the team owner', function () { - session(['currentTeam' => $this->team]); - - $this->actingAs($this->owner); - Livewire::test(TeamIndex::class)->assertSee('Danger Zone'); - - foreach ([$this->admin, $this->member] as $user) { - $this->actingAs($user); - Livewire::test(TeamIndex::class)->assertDontSee('Danger Zone'); - } -}); - test('admin cannot delete team via policy', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); diff --git a/tests/Feature/Jobs/StartupExecutionCleanupTest.php b/tests/Feature/Jobs/StartupExecutionCleanupTest.php index 9abbde990a..0b5cc4af2b 100644 --- a/tests/Feature/Jobs/StartupExecutionCleanupTest.php +++ b/tests/Feature/Jobs/StartupExecutionCleanupTest.php @@ -30,7 +30,12 @@ beforeEach(function () { // Fake notifications to ensure none are sent Notification::fake(); - Http::fake(); + // app:init pulls the service templates and writes the real repository file, + // so answer with its current content instead of an empty body. + Http::fake([ + config('constants.services.official') => Http::response(file_get_contents(service_templates_path())), + '*' => Http::response(), + ]); Process::fake(); Queue::fake(); diff --git a/tests/Feature/PullServiceTemplatesFromCdnTest.php b/tests/Feature/PullServiceTemplatesFromCdnTest.php index d70d787a76..848ad44b69 100644 --- a/tests/Feature/PullServiceTemplatesFromCdnTest.php +++ b/tests/Feature/PullServiceTemplatesFromCdnTest.php @@ -119,33 +119,6 @@ it('skips pulling templates in local development', function () { expect(Cache::get(service_templates_cache_key()))->toBeNull(); }); -it('keeps the local templates when the CDN returns an unusable bundle', function (string $body) { - Http::fake([ - config('constants.services.official') => Http::response($body, 200), - ]); - - $path = service_templates_path(); - $original = File::get($path); - - try { - config(['app.env' => 'production']); - - (new PullTemplatesFromCDN)->handle(); - - expect(md5(File::get($path)))->toBe(md5($original)) - ->and(Cache::get(service_templates_cache_key()))->toBeNull() - ->and(get_service_templates())->not->toBeEmpty(); - } finally { - File::put($path, $original); - Cache::forget(service_templates_cache_key()); - } -})->with([ - 'empty body' => '', - 'html error page' => 'Bad gateway', - 'empty object' => '{}', - 'json list' => '[1, 2]', -]); - it('logs when the CDN responds with a non-success status', function () { Http::fake([ 'cdn.coollabs.io/*' => Http::response('nope', 503),