fix: regressions found by the test suite

- TrustHosts: validate the Host header also when the FQDN cache is cold.
- Settings email: authorize the SMTP and Resend toggles.
- Server navbar: show Sentinel status only to users who can view Sentinel.
- GitHub source: restore permission gates on Sync name and Delete, and the
  Test connection button.
- Profile: make avatar upload work (missing import and file uploads trait).
- API tokens: keep the "Never" expiry choice.
- Environment variables: show validation errors instead of a generic error.
- Servers API: read the server uuid from the route in domains_by_server.
- Configuration diff: report all settings when no earlier snapshot exists.
- Cloud servers: restore Power On for stopped servers; do not repeat the
  provider error prefix.
- HasSecretManager: do not stop later deleting listeners.
- GitLab webhook: use new_public_id() like the other webhooks.
- Dev: show the Lima VM start command on the localhost server page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-09-28 00:18:09 +02:00
1 parent 1e66c9f9bd
commit a32082b03f
15 files changed
+98 -38

No files matched your search

@@ -305,7 +305,7 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
+3 -4
View File
@@ -17,7 +17,6 @@ use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
@@ -185,7 +184,7 @@ class Gitlab extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -203,7 +202,7 @@ class Gitlab extends Controller
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
]);
@@ -275,7 +274,7 @@ class Gitlab extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->first();
+29 -23
View File
@@ -28,8 +28,7 @@ class TrustHosts extends Middleware
}
// Skip host validation if no FQDN is configured (initial setup)
$fqdnHost = Cache::get('instance_settings_fqdn_host');
if ($fqdnHost === '' || $fqdnHost === null) {
if ($this->configuredFqdnHost() === null) {
return $next($request);
}
@@ -46,27 +45,7 @@ class TrustHosts extends Middleware
{
$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;
$fqdnHost = $this->configuredFqdnHost();
if ($fqdnHost) {
$trustedHosts[] = $fqdnHost;
@@ -97,4 +76,31 @@ 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;
}
}
+4
View File
@@ -2,15 +2,19 @@
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;
@@ -17,6 +17,7 @@ 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;
@@ -188,7 +189,6 @@ class Show extends Component
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
@@ -330,6 +330,8 @@ 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);
}
+8 -2
View File
@@ -15,7 +15,13 @@ class ApiTokens extends Component
public ?string $description = null;
public ?int $expiresInDays = 30;
/**
* 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 $tokens = [];
@@ -143,7 +149,7 @@ class ApiTokens extends Component
throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.');
}
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null;
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt);
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
+3 -1
View File
@@ -296,7 +296,9 @@ class ByDigitalOcean extends Component
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
}
return "{$providerName} API error: {$details}";
$prefix = "{$providerName} API error: ";
return str_starts_with($details, $prefix) ? $details : $prefix.$details;
}
public function getAvailableSizesProperty(): array
+3 -1
View File
@@ -375,7 +375,9 @@ class ByVultr extends Component
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
}
return "{$providerName} API error: {$details}";
$prefix = "{$providerName} API error: ";
return str_starts_with($details, $prefix) ? $details : $prefix.$details;
}
private function createVultrServer(VultrService $vultrService): array
+2
View File
@@ -163,6 +163,7 @@ class SettingsEmail extends Component
public function toggleSmtp()
{
try {
$this->authorize('update', $this->settings);
$this->resetErrorBag();
if ($this->smtpEnabled) {
@@ -185,6 +186,7 @@ class SettingsEmail extends Component
public function toggleResend()
{
try {
$this->authorize('update', $this->settings);
$this->resetErrorBag();
if ($this->resendEnabled) {
@@ -39,7 +39,9 @@ class ConfigurationDiffer
$previous = $previousItems[$key] ?? null;
$current = $currentItems[$key] ?? null;
if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) {
// 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)) {
continue;
}
+4 -1
View File
@@ -15,7 +15,10 @@ trait HasSecretManager
public static function bootHasSecretManager(): void
{
static::deleting(fn ($resource) => $resource->secretManagerLink()->delete());
// Return nothing: a non-null result halts the "deleting" event and skips later listeners.
static::deleting(function ($resource): void {
$resource->secretManagerLink()->delete();
});
}
public function secretManagerLink(): MorphOne
@@ -85,7 +85,9 @@
$activeServerMenuItem['route'],
$serverRouteParameters,
);
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
$showSentinelStatus = $server->isFunctional()
&& $server->isSentinelEnabled()
&& auth()->user()?->can('viewSentinel', $server);
$proxyCanBeStopped = in_array($proxyStatus, ['running', 'starting', 'restarting'], true);
@endphp
@@ -13,6 +13,13 @@
:type="$server->isFunctional() ? 'success' : 'warning'" />
</x-slot:actions>
@if ($this->limaStartCommand)
<x-callout type="info" title="Start this Lima VM locally" class="mb-4">
<code
class="mt-2 block overflow-x-auto rounded-lg bg-neutral-950 px-3 py-2 font-mono text-[11px] text-neutral-200">{{ $this->limaStartCommand }}</code>
</x-callout>
@endif
<div class="flex items-start gap-3">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
@@ -88,6 +88,24 @@
<x-reicon name="refresh" class="size-3.5" />
</x-forms.button>
@endif
@if ($server->cloudProviderToken)
@if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off')
<x-forms.button type="button" wire:click.prevent="startHetznerServer" isHighlighted
canGate="update" :canResource="$server">
Power On
</x-forms.button>
@elseif ($provider === 'DigitalOcean' && $digitalOceanDropletStatus === 'off')
<x-forms.button type="button" wire:click.prevent="startDigitalOceanDroplet"
isHighlighted canGate="update" :canResource="$server">
Power On
</x-forms.button>
@elseif ($provider === 'Vultr' && $vultrInstanceStatus === 'stopped')
<x-forms.button type="button" wire:click.prevent="startVultrInstance" isHighlighted
canGate="update" :canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@endif
@if ($server->server_metadata)
<x-forms.button type="button" class="size-8! px-0!"
@@ -104,7 +104,12 @@
<x-application.settings-section title="General"
description="Connection and authentication settings for this private GitHub source.">
<x-slot:actions>
<x-forms.button type="button" wire:click.prevent="updateGithubAppName">
@if ($isConnected)
<x-forms.button type="button" canGate="view" :canResource="$github_app" wire:click.prevent="testConnection">
Test connection
</x-forms.button>
@endif
<x-forms.button type="button" canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
<x-reicon name="refresh" class="size-3.5" />
Sync name
</x-forms.button>
@@ -191,7 +196,7 @@
shortConfirmationLabel="GitHub App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button isError disabled tooltip="You do not have permission to delete this GitHub App.">
<x-forms.button isError disabled canGate="delete" :canResource="$github_app" tooltip="You do not have permission to delete this GitHub App.">
Delete
</x-forms.button>
@endcan