diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 123b752c0..004403975 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -56,7 +56,7 @@ public function execute(ServiceApplication $serviceApplication, Request $request } } - $serviceApplication->fqdn = $parsed['normalized']; + $serviceApplication->setEditableUrls($parsed['normalized']); } if (array_key_exists('noindex_domains', $payload)) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index a0e1714ad..487d319e1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -23,6 +23,7 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -2487,6 +2488,256 @@ public function logs_by_uuid(Request $request) ]); } + #[OA\Patch( + summary: 'Update Preview Domains', + description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'update-preview-domains-by-pull-request-id', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'), + new OA\Property( + property: 'docker_compose_domains', + type: 'array', + nullable: true, + items: new OA\Items(properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'domain', type: 'string', nullable: true), + new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']), + ], type: 'object'), + ), + new OA\Property(property: 'force_domain_override', type: 'boolean', default: false), + ], + )), + responses: [ + new OA\Response(response: 200, description: 'Preview domains updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, ref: '#/components/responses/403'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Domain conflict.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update_preview_by_pull_request_id(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', (int) $pullRequestIdRaw) + ->first(); + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value; + $validationRules = ['force_domain_override' => 'boolean']; + if ($isCompose) { + $validationRules = array_merge($validationRules, [ + 'domains' => 'missing', + 'docker_compose_domains' => 'present|array', + 'docker_compose_domains.*' => 'array:name,domain,redirect', + 'docker_compose_domains.*.name' => 'required|string|distinct', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', + ]); + } else { + $validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()]; + $validationRules['docker_compose_domains'] = 'missing'; + } + + $validator = Validator::make($request->all(), $validationRules); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $dockerComposeDomains = null; + $dockerComposeDomainsResponse = null; + if ($isCompose) { + try { + $compose = Yaml::parse($application->docker_compose_raw ?? ''); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'], + ], 422); + } + + $services = data_get($compose, 'services'); + if (! is_array($services) || $services === []) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'], + ], 422); + } + + $composeServices = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->keys() + ->map(fn (mixed $name): string => (string) $name) + ->values(); + $requestedServices = collect($request->input('docker_compose_domains'))->pluck('name'); + if ($requestedServices->diff($composeServices)->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'], + ], 422); + } + + $existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: []; + $dockerComposeDomains = $composeServices + ->mapWithKeys(function (string $service) use ($existingComposeDomains): array { + $entry = ['domain' => '']; + $redirect = $existingComposeDomains[$service]['redirect'] ?? null; + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + + return [$service => $entry]; + }) + ->all(); + foreach ($request->input('docker_compose_domains') as $item) { + $entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? '']; + $redirect = array_key_exists('redirect', $item) + ? data_get($item, 'redirect') + : ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomains[data_get($item, 'name')] = $entry; + } + $domains = collect($dockerComposeDomains) + ->pluck('domain') + ->filter() + ->implode(',') ?: null; + } else { + $domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains')); + } + + $submittedUrls = collect(ValidationPatterns::applicationDomainList($domains)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)); + if ($submittedUrls->duplicates()->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + $isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.', + ], + ], 422); + } + + $normalized = DomainPortOverrides::normalize($domains, null); + $portlessDomains = $normalized['fqdn']; + if ($isCompose) { + foreach ($dockerComposeDomains as $service => $entry) { + $dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'])) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + } + $dockerComposeDomainsResponse = collect($dockerComposeDomains) + ->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry]) + ->values() + ->all(); + } + $urls = collect(ValidationPatterns::applicationDomainList($portlessDomains)); + $conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($conflicts['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']], + ], 422); + } + if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $conflicts['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $hostCandidates = $urls + ->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST)) + ->filter(); + $conflictingPreview = null; + if ($hostCandidates->isNotEmpty()) { + $conflictingPreview = ApplicationPreview::query() + ->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId) + ->withoutGlobalScope('withRelations') + ->reorder() + ->select('applications.id')) + ->whereKeyNot($preview->id) + ->whereNotNull('fqdn') + ->where(function ($query) use ($hostCandidates): void { + foreach ($hostCandidates as $host) { + $query->orWhere('fqdn', 'like', '%'.$host.'%'); + } + }) + ->get(['uuid', 'pull_request_id', 'fqdn']) + ->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->isNotEmpty()); + } + + if ($conflictingPreview && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => [[ + 'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->first(), + 'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id, + 'resource_uuid' => $conflictingPreview->uuid, + 'resource_type' => 'application', + 'message' => 'Domain is already in use by another preview deployment.', + ]], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $preview->domain_port_overrides = $normalized['overrides']; + $preview->fqdn = $portlessDomains; + if ($isCompose) { + $preview->docker_compose_domains = json_encode($dockerComposeDomains); + } + $preview->save(); + + auditLog('api.application.preview_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'], + ]); + + return response()->json([ + 'uuid' => $preview->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'domains' => $preview->fqdn, + 'docker_compose_domains' => $dockerComposeDomainsResponse, + 'domain_port_overrides' => $preview->domain_port_overrides, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete application by UUID.', @@ -2818,6 +3069,7 @@ public function update_by_uuid(Request $request) 'http_basic_auth_username' => 'string', 'http_basic_auth_password' => 'string', 'include_source_commit_in_build' => 'boolean', + 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -2826,10 +3078,10 @@ public function update_by_uuid(Request $request) $validator = Validator::make($request->all(), $validationRules, $validationMessages); // Validate ports_exposes - if ($request->has('ports_exposes')) { + if ($request->filled('ports_exposes')) { $ports = explode(',', $request->ports_exposes); foreach ($ports as $port) { - if (! is_numeric($port)) { + if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -5152,7 +5404,7 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons $this->authorize('delete', $application); $pullRequestIdRaw = $request->route('pull_request_id'); - if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { return response()->json(['message' => 'Invalid pull_request_id.'], 422); } $pullRequestId = (int) $pullRequestIdRaw; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index d736ec385..9f1e7fc17 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -8,6 +8,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -57,7 +58,7 @@ class Domains extends Component public ?string $editingService = null; - /** @var array */ + /** @var array */ public array $domainRows = []; /** When set, the next addSuggestedDomain call for this index skips the DNS block. */ @@ -70,6 +71,14 @@ class Domains extends Component public bool $showDomainConflictModal = false; + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + public bool $forceSaveDomains = false; public bool $forceSaveDns = false; @@ -485,32 +494,19 @@ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOv /** * @param array $stored - * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool} + * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool} */ protected function domainRowFromStored(string $url, ?string $service, array $stored): array { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; + $port = $this->effectiveDomainInternalPort($url); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'url' => $url, - 'service' => $service, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'check_id' => data_get($entry, 'check_id'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'url' => $url, 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, @@ -521,6 +517,104 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->application->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + if ($this->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $exposed = $this->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + protected function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + protected function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->application->portRequiresConfirmation($port); + } + + protected function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; } /** @@ -824,6 +918,31 @@ public function confirmDomainUsage(): void $this->addDomain(); } + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + /** * Clear pending conflict state when the modal is dismissed without confirmation. * confirmDomainUsage sets forceSaveDomains before closing the modal. @@ -848,7 +967,7 @@ public function addDomain(): void return; } - if ($this->newDomainPartsChanged) { + if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) { $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); } $this->validateOnly('newDomain'); @@ -867,15 +986,24 @@ public function addDomain(): void ->values() ->all(); $current = $this->currentDomainList($this->newDomainService); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured."); return; } } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } + $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -884,6 +1012,7 @@ public function addDomain(): void $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); @@ -1110,6 +1239,11 @@ public function startEdit(int $index): void $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); @@ -1227,7 +1361,7 @@ public function updateDomain(): void return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); @@ -1244,13 +1378,29 @@ public function updateDomain(): void $service = $this->editingService; $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $this->application->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $this->application->domain_port_overrides = $portOverrides ?: null; + } + $current = $this->currentDomainList($service); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured."); return; } + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { $dnsFailure = $this->findDnsFailureMessage([$newUrl]); if ($dnsFailure !== null) { @@ -1278,6 +1428,7 @@ public function updateDomain(): void $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->dispatch('success', 'Domain updated.'); @@ -1823,6 +1974,8 @@ protected function saveDomainList( } } + $intendedComposeOverrides = null; + if ($this->isCompose) { if (blank($serviceName)) { $this->dispatch('error', 'A service is required for compose domains.'); @@ -1838,6 +1991,15 @@ protected function saveDomainList( $allDomains = []; } + $previousServiceUrls = $this->currentDomainList($serviceName); + $normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides); + $domainString = $normalizedPorts['fqdn']; + $intendedComposeOverrides = $this->mergeComposeDomainPortOverrides( + $previousServiceUrls, + $domainString, + $normalizedPorts['overrides'] ?? null, + ); + $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; // Preserve stored redirect only — pending Direction dropdown values must not // persist until setServiceRedirect() runs. @@ -1846,6 +2008,7 @@ protected function saveDomainList( ]); $this->application->docker_compose_domains = json_encode($allDomains); + $this->application->domain_port_overrides = $intendedComposeOverrides; $this->application->fqdn = null; } else { $this->application->fqdn = $domainString; @@ -1872,12 +2035,47 @@ protected function saveDomainList( } $this->application->save(); + + if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) { + $this->application->domain_port_overrides = $intendedComposeOverrides; + $this->application->save(); + } + $this->resetDefaultLabels(); $this->dispatch('configurationChanged'); return true; } + /** + * @param Collection $previousServiceUrls + * @param array|null $incomingOverrides + * @return array|null + */ + protected function mergeComposeDomainPortOverrides( + Collection $previousServiceUrls, + ?string $newDomainString, + ?array $incomingOverrides, + ): ?array { + $merged = $this->application->domain_port_overrides ?? []; + $newCanonical = collect($this->splitDomains($newDomainString)) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->all(); + + foreach ($previousServiceUrls as $url) { + $canonical = DomainPortOverrides::withoutPort($url); + if (! in_array($canonical, $newCanonical, true)) { + unset($merged[$canonical]); + } + } + + foreach ($incomingOverrides ?? [] as $url => $port) { + $merged[$url] = (int) $port; + } + + return $merged ?: null; + } + protected function resetDefaultLabels(): void { try { diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index f2557e4f5..21296978f 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -5,12 +5,12 @@ use App\Actions\Shared\CheckDomainDns; use App\Jobs\CheckDomainDnsJob; use App\Models\ApplicationPreview; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; -use Spatie\Url\Url; class PreviewDomains extends Component { @@ -28,6 +28,14 @@ class PreviewDomains extends Component public ?int $editingIndex = null; + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + public function mount(): void { $this->refreshDomains(); @@ -57,11 +65,20 @@ public function addDomain(): void if ($domain === null) { return; } - if (collect($this->domainRows)->contains(fn (array $row): bool => $row['url'] === $domain && $row['service'] === $this->newDomainService)) { + $canonicalDomain = DomainPortOverrides::withoutPort($domain); + if (collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain + && $row['service'] === $this->newDomainService + )) { $this->addError('newDomainParts.host', 'This domain is already configured.'); return; } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } $this->domainRows[] = $this->makeRow($domain, $this->newDomainService); $index = array_key_last($this->domainRows); $checkId = new_public_id(); @@ -71,10 +88,12 @@ public function addDomain(): void if (! $this->persistDomains()) { return; } + $domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain); $this->newDomainParts = DomainUrlParts::empty(); $this->newDomainService = $this->preview->application->build_pack === 'dockercompose' ? ($this->composeServices()[0] ?? null) : null; + $this->forceUseUnknownPort = false; $this->dispatch('close-modal'); try { @@ -109,7 +128,8 @@ public function generateDomain(): void $service = $this->newDomainService ?? data_get($this->domainRows, '0.service'); foreach ($this->generateComposeDomains((string) $service) as $domain) { $alreadyExists = collect($this->domainRows)->contains( - fn (array $row): bool => $row['url'] === $domain && $row['service'] === $service + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain) + && $row['service'] === $service ); if (! $alreadyExists) { $this->domainRows[] = $this->makeRow($domain, $service); @@ -134,6 +154,11 @@ public function startEdit(int $index): void } $this->editingIndex = $index; $this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']); + $canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']); + $savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->dispatch('open-preview-domain-edit'); } @@ -147,6 +172,18 @@ public function updateDomain(): void if ($domain === null) { return; } + $oldUrl = $this->domainRows[$this->editingIndex]['url']; + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (blank(DomainUrlParts::split($domain)['port'] ?? null)) { + $portOverrides = $this->preview->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($domain)]); + $this->preview->domain_port_overrides = $portOverrides ?: null; + } $this->domainRows[$this->editingIndex]['url'] = $domain; $this->domainRows[$this->editingIndex]['dns_status'] = 'pending'; $this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.'; @@ -155,11 +192,37 @@ public function updateDomain(): void if (! $this->persistDomains()) { return; } + $this->forceUseUnknownPort = false; $this->dispatch('close-preview-domain-edit'); $this->dispatch('success', 'Domain updated.'); $this->checkDomainDns($index); } + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->preview->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + public function removeDomain(int $index): void { $this->authorize('update', $this->preview->application); @@ -299,8 +362,24 @@ private function persistDomains(): bool } else { $this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null; } + $normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides); + $this->preview->fqdn = $normalized['fqdn']; + $this->preview->domain_port_overrides = $normalized['overrides']; + if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) { + foreach ($domains as $service => $entry) { + $serviceDomains = $this->splitDomains(composeDomainEntryString($entry)); + $domains[$service]['domain'] = collect($serviceDomains) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->implode(','); + } + $this->preview->docker_compose_domains = json_encode($domains); + } + foreach ($this->domainRows as $index => $row) { + $this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']); + } $this->preview->save(); $this->persistDnsStatuses(); + $this->refreshDomains(); $this->dispatch('update_links'); $this->dispatch('previewDomainsChanged'); @@ -360,16 +439,107 @@ private function validatedDomain(array $parts, string $errorKey): ?string private function makeRow(string $url, ?string $service, array $statuses = []): array { $status = $statuses[$this->statusKey($url, $service)] ?? []; + $port = $this->effectiveDomainInternalPort($url); return [ 'url' => $url, 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => $status['status'] ?? 'pending', 'dns_message' => $status['message'] ?? 'DNS has not been checked yet.', 'check_id' => $status['check_id'] ?? null, ]; } + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + private function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + private function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->preview->application->portRequiresConfirmation($port); + } + + private function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + private function effectiveDomainInternalPort(string $url): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->preview->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + if ($this->preview->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $exposed = $this->preview->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + private function statusKey(string $url, ?string $service): string { return hash('sha256', $url.'|'.($service ?? '')); @@ -393,14 +563,14 @@ private function generateComposeDomains(string $service): array } return collect($this->splitDomains($domainString))->map(function (string $domain): string { - $url = Url::fromString($domain); - $generatedDomain = str_replace('{{random}}', new_public_id(), $this->preview->application->preview_url_template); - $generatedDomain = str_replace('{{domain}}', $url->getHost(), $generatedDomain); - $generatedDomain = str_replace('{{pr_id}}', (string) $this->preview->pull_request_id, $generatedDomain); - $port = $url->getPort() !== null ? ':'.$url->getPort() : ''; - $path = ! in_array($url->getPath(), ['', '/'], true) ? $url->getPath() : ''; + $generated = $this->preview->generatedPreviewDomain($domain); + if (filled($generated['port'])) { + $overrides = $this->preview->domain_port_overrides ?? []; + $overrides[$generated['url']] = $generated['port']; + $this->preview->domain_port_overrides = $overrides; + } - return "{$url->getScheme()}://{$generatedDomain}{$port}{$path}"; + return $generated['url']; })->all(); } @@ -413,7 +583,6 @@ private function composeServices(bool $failOnError = false): array return []; } - $usesLegacyServiceKeys = (int) $this->preview->application->compose_parsing_version < 3; $previewSuffix = '-pr-'.$this->preview->pull_request_id; $serviceNames = []; foreach ($services as $serviceName => $service) { @@ -422,7 +591,7 @@ private function composeServices(bool $failOnError = false): array } $serviceName = (string) $serviceName; - if ($usesLegacyServiceKeys && str_ends_with($serviceName, $previewSuffix)) { + if (str_ends_with($serviceName, $previewSuffix)) { $serviceName = substr($serviceName, 0, -strlen($previewSuffix)); } $serviceNames[] = $serviceName; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index b5ca45b87..d5254e093 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -306,30 +307,15 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr { $entry = $stored[$url] ?? null; $displayName = $app->human_name ?: $app->name; + $port = $this->effectiveDomainInternalPort($url, $app); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'service_application_id' => $app->id, - 'service_name' => $displayName, - 'service_image' => $app->image, - 'url' => $url, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'check_id' => data_get($entry, 'check_id'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'service_application_id' => $app->id, 'service_name' => $displayName, 'service_image' => $app->image, 'url' => $url, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, @@ -340,6 +326,48 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $app->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + + if (array_key_exists($canonical, $overrides)) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null && $legacyPort > 0) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + $requiredPort = $app->getRequiredPort(); + + return [ + 'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null, + 'has_port_override' => false, + ]; } /** @@ -990,8 +1018,11 @@ public function addDomain(): void ->all() : []; $current = collect($this->splitDomains($app->fqdn)); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured for this service."); return; @@ -1111,6 +1142,12 @@ public function startEdit(int $index): void $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $app = $this->findServiceApp((int) $this->domainRows[$index]['service_application_id']); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($app?->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editDomainDnsFailed = false; @@ -1144,7 +1181,7 @@ public function updateDomain(): void return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); @@ -1166,7 +1203,17 @@ public function updateDomain(): void $current = collect($this->splitDomains($app->fqdn)); $wasNoindexed = $app->isDomainNoindexed($oldUrl); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $app->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $app->domain_port_overrides = $portOverrides ?: null; + } + + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured for this service."); return; @@ -1398,8 +1445,9 @@ protected function saveDomainListForApp( if (! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { + $previousFqdn = $app->getOriginal('fqdn'); foreach ($this->splitDomains($domainString) as $fqdn) { - if (ServiceApplication::extractPortFromUrl($fqdn) === null) { + if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) { $this->requiredPort = $requiredPort; $this->showPortWarningModal = true; $app->refresh(); diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index bfcd04ac5..f09989153 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -52,12 +52,12 @@ private function syncData(bool $toModel = false): void $this->validate(); // Sync to model - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); $this->application->save(); } else { // Sync from model - $this->fqdn = $this->application->fqdn; + $this->fqdn = $this->application->url; } } @@ -84,6 +84,10 @@ public function cancelRemovePort() public function submit() { try { + $persistedApplication = $this->application->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->application); $this->validate(); @@ -93,7 +97,7 @@ public function submit() $this->dispatch('warning', __('warning.sslipdomain')); } // Sync to model for domain conflict check (without validation) - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); // Check for domain conflicts if not forcing save if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); @@ -113,29 +117,21 @@ public function submit() $requiredPort = $this->application->getRequiredPort(); if ($requiredPort !== null) { - // Check if all FQDNs have a port - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->application->fqdn = $previousFqdn; + $this->application->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { // Reset the force flag after using it diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 23ee6a096..7980e0705 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -99,10 +99,27 @@ class Index extends Component 'isStripprefixEnabled' => 'nullable|boolean', ]; - public function mount() + public function mount(?ServiceApplication $serviceApplication = null) { try { $this->services = collect([]); + if ($serviceApplication) { + $this->service = $serviceApplication->service; + $this->authorize('view', $this->service); + $this->parameters = [ + 'project_uuid' => $this->service->environment->project->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'stack_service_uuid' => $serviceApplication->uuid, + ]; + $this->query = request()->query(); + $this->serviceApplication = $serviceApplication; + $this->resourceType = 'application'; + $this->initializeApplicationProperties(); + $this->s3s = currentTeam()->s3s; + + return; + } $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); @@ -350,7 +367,7 @@ private function syncApplicationData(bool $toModel = false): void if ($toModel) { $this->serviceApplication->human_name = $this->humanName; $this->serviceApplication->description = $this->description; - $this->serviceApplication->fqdn = $this->fqdn; + $this->serviceApplication->setEditableUrls($this->fqdn); $this->serviceApplication->image = $this->image; $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; @@ -359,7 +376,7 @@ private function syncApplicationData(bool $toModel = false): void } else { $this->humanName = $this->serviceApplication->human_name; $this->description = $this->serviceApplication->description; - $this->fqdn = $this->serviceApplication->fqdn; + $this->fqdn = $this->serviceApplication->url; $this->image = $this->serviceApplication->image; $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); @@ -485,6 +502,10 @@ public function cancelRemovePort() public function submitApplication() { try { + $persistedApplication = $this->serviceApplication->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->serviceApplication); $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), @@ -514,28 +535,21 @@ public function submitApplication() $requiredPort = $this->serviceApplication->getRequiredPort(); if ($requiredPort !== null) { - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->serviceApplication->fqdn = $previousFqdn; + $this->serviceApplication->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { $this->forceRemovePort = false; diff --git a/app/Models/Application.php b/app/Models/Application.php index 4873a9146..f8eb75a5a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -7,6 +7,8 @@ use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -135,6 +137,7 @@ class Application extends BaseModel 'description', 'fqdn', 'noindex_domains', + 'domain_port_overrides', 'git_repository', 'git_branch', 'git_commit_sha', @@ -246,6 +249,7 @@ class Application extends BaseModel 'docker_compose_raw', 'custom_labels', 'domain_dns_statuses', + 'domain_port_overrides', ]; protected function casts(): array @@ -258,6 +262,7 @@ protected function casts(): array 'manual_webhook_secret_gitea' => 'encrypted', 'noindex_domains' => 'array', 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'restart_count' => 'integer', 'max_restart_count' => 'integer', 'restart_limit_reached' => 'boolean', @@ -286,6 +291,9 @@ protected static function booted() if ($application->fqdn === '') { $application->fqdn = null; } + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } @@ -974,6 +982,46 @@ public function main_port() return $this->settings->is_static ? [80] : $this->ports_exposes_array; } + /** + * Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains. + * + * @return list + */ + public function availableInternalPorts(): array + { + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) + ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) + ->map(fn (mixed $port): int => (int) $port); + + foreach ($this->domain_port_overrides ?? [] as $port) { + if (is_numeric($port) && (int) $port > 0) { + $ports->push((int) $port); + } + } + + foreach (explode(',', (string) $this->fqdn) as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + $legacyPort = DomainUrlParts::split($url)['port'] ?? ''; + if ($legacyPort !== '' && is_numeric($legacyPort) && (int) $legacyPort > 0) { + $ports->push((int) $legacyPort); + } + } + + return $ports->unique()->sort()->values()->all(); + } + + public function portRequiresConfirmation(?int $port): bool + { + if ($port === null || $port <= 0) { + return false; + } + + return ! in_array($port, $this->availableInternalPorts(), true); + } + public function detectPortFromEnvironment(?bool $isPreview = false): ?int { $envVars = $isPreview diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 381e71817..bffbdab62 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\SoftDeletes; @@ -25,11 +26,17 @@ class ApplicationPreview extends BaseModel 'docker_registry_image_tag', 'last_online_at', 'domain_dns_statuses', + 'domain_port_overrides', + ]; + + protected $hidden = [ + 'domain_port_overrides', ]; protected $casts = [ 'pull_request_id' => 'integer', 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', ]; protected static function booted(): void @@ -85,6 +92,14 @@ protected static function booted(): void if ($preview->isDirty('status')) { $preview->last_online_at = now(); } + if ($preview->isDirty('fqdn')) { + if ($preview->fqdn === '') { + $preview->fqdn = null; + } + $normalized = DomainPortOverrides::normalize($preview->fqdn, $preview->domain_port_overrides); + $preview->fqdn = $normalized['fqdn']; + $preview->domain_port_overrides = $normalized['overrides']; + } }); } @@ -124,24 +139,14 @@ public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = f } if ($applicationFqdn) { - if (str($applicationFqdn)->contains(',')) { - $url = Url::fromString(str($applicationFqdn)->explode(',')[0]); - } else { - $url = Url::fromString($applicationFqdn); - } - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $this->fqdn = $preview_fqdn; + $sourceDomain = str($applicationFqdn)->contains(',') + ? str($applicationFqdn)->explode(',')[0] + : $applicationFqdn; + $generated = $this->generatedPreviewDomain((string) $sourceDomain); + $this->fqdn = $generated['url']; + $this->domain_port_overrides = filled($generated['port']) + ? [$generated['url'] => $generated['port']] + : null; $this->save(); } @@ -187,6 +192,7 @@ public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDo ->all(); $docker_compose_domains = []; + $previewPortOverrides = []; foreach ($serviceNames as $service_name) { $domain_string = getComposeServiceDomainString($applicationDomains, $service_name); @@ -218,20 +224,11 @@ public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDo continue; } - $url = Url::fromString($domain); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $preview_domains[] = $preview_fqdn; + $generated = $this->generatedPreviewDomain((string) $domain); + $preview_domains[] = $generated['url']; + if (filled($generated['port'])) { + $previewPortOverrides[$generated['url']] = $generated['port']; + } } $docker_compose_domains = putComposeServiceDomain( @@ -255,10 +252,36 @@ public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDo ->implode(','); $this->fqdn = ! empty($allDomains) ? $allDomains : null; + $this->domain_port_overrides = $previewPortOverrides ?: null; $this->save(); } + /** + * @return array{url: string, port: ?int} + */ + public function generatedPreviewDomain(string $sourceDomain): array + { + $url = Url::fromString($sourceDomain); + $template = $this->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $urlPath = $url->getPath(); + $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; + $random = new_public_id(); + $previewFqdn = str_replace('{{random}}', $random, $template); + $previewFqdn = str_replace('{{domain}}', $host, $previewFqdn); + $previewFqdn = str_replace('{{pr_id}}', (string) $this->pull_request_id, $previewFqdn); + $previewUrl = "{$schema}://{$previewFqdn}{$path}"; + $sourceCanonical = DomainPortOverrides::withoutPort($sourceDomain); + $port = $url->getPort() ?? ($this->application->domain_port_overrides[$sourceCanonical] ?? null); + + return [ + 'url' => $previewUrl, + 'port' => $port !== null ? (int) $port : null, + ]; + } + /** * Original compose service names for this preview (PR suffix stripped), excluding database images. * diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301..66c67ca8d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,6 +4,7 @@ use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Support\DomainPortOverrides; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -92,11 +93,18 @@ protected static function booted() public function isConfigurationChanged(bool $save = false) { - $domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray(); + $applications = $this->applications()->get(); + $domains = $applications->pluck('fqdn')->sort()->toArray(); $domains = implode(',', $domains); - $noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $noindexDomains = $applications->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $domainPortOverrides = $applications + ->mapWithKeys(fn (ServiceApplication $application): array => [ + $application->id => DomainPortOverrides::sorted($application->domain_port_overrides), + ]) + ->sortKeys() + ->all(); - $applicationImages = $this->applications()->get()->pluck('image')->sort(); + $applicationImages = $applications->pluck('image')->sort(); $databaseImages = $this->databases()->get()->pluck('image')->sort(); $images = $applicationImages->merge($databaseImages); $images = implode(',', $images->toArray()); @@ -105,7 +113,7 @@ public function isConfigurationChanged(bool $save = false) $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages.$noindexDomains; + $newConfigHash = $images.$domains.$images.$storages.$noindexDomains.json_encode($domainPortOverrides); $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -1496,7 +1504,7 @@ public function getRequiredPort(): ?int { try { $services = get_service_templates(); - $serviceName = str($this->name)->beforeLast('-')->value(); + $serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value(); $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index f6284ecbf..cf0faef5b 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\HasNoindexDomains; use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -13,6 +15,8 @@ class ServiceApplication extends BaseModel { use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes; + protected $appends = ['url']; + protected $fillable = [ 'service_id', 'name', @@ -22,6 +26,7 @@ class ServiceApplication extends BaseModel 'noindex_domains', 'redirect', 'domain_dns_statuses', + 'domain_port_overrides', 'ports', 'exposes', 'status', @@ -44,6 +49,7 @@ class ServiceApplication extends BaseModel */ protected $hidden = [ 'domain_dns_statuses', + 'domain_port_overrides', ]; protected $attributes = [ @@ -54,6 +60,7 @@ protected function casts(): array { return [ 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'noindex_domains' => 'array', 'is_force_https_enabled' => 'boolean', ]; @@ -71,6 +78,7 @@ protected static function booted() $service->last_online_at = now(); } if ($service->isDirty('fqdn')) { + $service->normalizeDomainPortOverrides(); $service->syncNoindexDomains(); } }); @@ -192,6 +200,45 @@ public function fqdns(): Attribute ); } + /** + * Return the public URLs with their persisted internal port overrides. + */ + protected function url(): Attribute + { + return Attribute::make( + get: function (): ?string { + if (blank($this->fqdn)) { + return null; + } + + $overrides = $this->domain_port_overrides ?? []; + + return collect(explode(',', $this->fqdn)) + ->map(function (string $url) use ($overrides): string { + $url = trim($url); + $canonical = DomainPortOverrides::withoutPort($url); + $port = $overrides[$canonical] ?? null; + + if ($port === null) { + return $canonical; + } + + $parts = DomainUrlParts::split($canonical); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $port, $parts['path']); + }) + ->implode(','); + }, + ); + } + + public function setEditableUrls(?string $urls): void + { + $normalized = DomainPortOverrides::normalize($urls, null); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Extract port number from a given FQDN URL. * Returns null if no port is specified. @@ -213,6 +260,58 @@ public static function extractPortFromUrl(string $url): ?int } } + /** + * True when saving this URL should confirm that it does not use the required template port. + */ + public function portRequiresConfirmation(string $fqdn, ?int $requiredPort, ?string $previousFqdn = null): bool + { + if ($requiredPort === null) { + return false; + } + + $fqdn = trim($fqdn); + if ($fqdn === '') { + return false; + } + + $canonical = DomainPortOverrides::withoutPort($fqdn); + $explicit = self::extractPortFromUrl($fqdn); + + if ($explicit === $requiredPort) { + return false; + } + + if ($explicit === null) { + $previous = collect(explode(',', (string) $previousFqdn)) + ->filter(); + $previousUrl = $previous->first( + fn (string $url): bool => DomainPortOverrides::withoutPort(trim($url)) === $canonical + ); + + if (is_string($previousUrl) && self::extractPortFromUrl($previousUrl) !== null) { + return true; + } + + return $previousUrl === null; + } + + $existingOverride = $this->domain_port_overrides[$canonical] ?? null; + + return (int) $existingOverride !== $explicit; + } + + public static function withoutPort(string $url): string + { + return DomainPortOverrides::withoutPort($url); + } + + protected function normalizeDomainPortOverrides(): void + { + $normalized = DomainPortOverrides::normalize($this->fqdn, $this->domain_port_overrides); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Check if all FQDNs have a port specified. */ @@ -277,6 +376,7 @@ public function getRequiredPort(): ?int // Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment // (not variables that are merely referenced with ${VAR} syntax) $portFound = null; + $declaresHttpUrl = false; foreach ($environment as $key => $value) { if (is_int($key) && is_string($value)) { // List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value" @@ -285,6 +385,7 @@ public function getRequiredPort(): ?int // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -299,6 +400,7 @@ public function getRequiredPort(): ?int // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -315,8 +417,12 @@ public function getRequiredPort(): ?int return $portFound; } - // No port-specific variables found for this service, return null - // (DO NOT fall back to service-level port, as that applies to all services) + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT + // suffix), such as WordPress, inherit the one-click template `# port:`. + if ($declaresHttpUrl) { + return $this->service->getRequiredPort(); + } + return null; } catch (\Throwable $e) { return null; diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 386bdd5bb..e3ba77163 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -7,6 +7,7 @@ use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; +use App\Support\DomainPortOverrides; use Illuminate\Support\Arr; class ApplicationConfigurationSnapshot @@ -194,6 +195,7 @@ private function domainItems(): array { return [ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('domain_port_overrides', 'Domain port overrides', DomainPortOverrides::sorted($this->application->domain_port_overrides), 'redeploy'), $this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'), $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), diff --git a/app/Support/DomainPortOverrides.php b/app/Support/DomainPortOverrides.php new file mode 100644 index 000000000..320540ad6 --- /dev/null +++ b/app/Support/DomainPortOverrides.php @@ -0,0 +1,91 @@ +|null $overrides + * @return array + */ + public static function sorted(?array $overrides): array + { + return collect($overrides ?? [])->sortKeys()->all(); + } + + public static function withoutPort(string $url): string + { + $parts = DomainUrlParts::split($url); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], path: $parts['path']); + } + + /** + * @param array|null $existing + * @return array{fqdn: ?string, overrides: ?array} + */ + public static function normalize(?string $fqdn, ?array $existing): array + { + if (blank($fqdn)) { + return ['fqdn' => null, 'overrides' => null]; + } + + $existingOverrides = $existing ?? []; + $normalizedDomains = collect(explode(',', $fqdn)) + ->map(fn (string $domain): string => trim($domain)) + ->filter() + ->map(function (string $domain) use ($existingOverrides): array { + $portlessDomain = self::withoutPort($domain); + $parts = DomainUrlParts::split($domain); + $port = $parts['port'] !== '' + ? (int) $parts['port'] + : ($existingOverrides[$portlessDomain] ?? null); + + return ['domain' => $portlessDomain, 'port' => $port]; + }) + ->keyBy('domain') + ->values(); + + $effectiveOverrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]); + + $normalizedDomains = $normalizedDomains->map(function (array $domain) use ($effectiveOverrides): array { + if (filled($domain['port'])) { + return $domain; + } + + $counterpart = self::wwwCounterpart($domain['domain']); + $domain['port'] = $counterpart === null ? null : $effectiveOverrides->get($counterpart); + + return $domain; + }); + + $normalizedFqdn = $normalizedDomains->pluck('domain')->implode(','); + $overrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]) + ->all(); + + return [ + 'fqdn' => $normalizedFqdn === '' ? null : $normalizedFqdn, + 'overrides' => $overrides ?: null, + ]; + } + + private static function wwwCounterpart(string $url): ?string + { + $parts = DomainUrlParts::split($url); + $host = $parts['host']; + + if ($host === '') { + return null; + } + + $counterpartHost = str_starts_with(strtolower($host), 'www.') + ? substr($host, 4) + : 'www.'.$host; + + return DomainUrlParts::compose($parts['scheme'], $counterpartHost, path: $parts['path']); + } +} diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php index cdeb75e58..5d3ded154 100644 --- a/app/Support/ServiceComposeUrl.php +++ b/app/Support/ServiceComposeUrl.php @@ -25,15 +25,7 @@ public static function validateUrlString(?string $urlValue, bool $forceDomainOve ->map(fn ($url) => trim((string) $url)) ->filter(); - foreach ($urls as $url) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = "Invalid URL: {$url}"; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'], true)) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - } + $errors = ValidationPatterns::validateApplicationDomains($urls->implode(',')); $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index cb1a0de62..4656406fc 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -619,6 +619,13 @@ public static function validateApplicationDomains(mixed $value): array continue; } + $port = parse_url($url, PHP_URL_PORT); + if ($port !== null && ($port < 1 || $port > 65535)) { + $errors[] = "Invalid port for URL: {$url}. The port must be between 1 and 65535."; + + continue; + } + $unwrappedHost = trim((string) $host, '[]'); if (! str_contains($unwrappedHost, '.') && filter_var($unwrappedHost, FILTER_VALIDATE_IP) === false) { $errors[] = "Invalid URL: {$url}. The hostname must be a fully qualified domain name."; diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php index c3ba8a7d8..7afe0d4f2 100644 --- a/app/Traits/HasNoindexDomains.php +++ b/app/Traits/HasNoindexDomains.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Support\Collection; @@ -19,7 +20,7 @@ public function noindexDomains(): Collection { return collect($this->noindex_domains ?? []) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->unique() ->values(); } @@ -27,7 +28,7 @@ public function noindexDomains(): Collection public function isDomainNoindexed(string $domain): bool { return $this->noindexDomains()->contains( - ValidationPatterns::normalizeApplicationDomainUrl($domain) + $this->normalizeNoindexDomain($domain) ); } @@ -35,7 +36,7 @@ public function setNoindexDomains(iterable $domains): void { $this->noindex_domains = collect($domains) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->intersect($this->currentDomains()) ->unique() ->values() @@ -58,6 +59,13 @@ public function syncNoindexDomains(): void private function currentDomains(): Collection { return collect(ValidationPatterns::applicationDomainList($this->fqdn)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)); + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)); + } + + private function normalizeNoindexDomain(string $domain): string + { + return DomainPortOverrides::withoutPort( + ValidationPatterns::normalizeApplicationDomainUrl($domain) + ); } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 00300d26a..f80fccafb 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -530,7 +530,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, array $domainPortOverrides = []) { $labels = collect([]); if ($serviceLabels) { @@ -554,7 +554,8 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($schema === 'https' && ! $is_force_https_enabled) { $siteAddress = "http://{$host}, https://{$host}"; } - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; if (! $is_stripprefix_enabled) { $handle = 'handle'; @@ -600,7 +601,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -655,7 +656,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $host = $url->getHost(); $path = $url->getPath(); $schema = $url->getScheme(); - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); if (is_null($port) && ! is_null($onlyPort)) { $port = $onlyPort; } @@ -898,6 +900,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -914,6 +917,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; } @@ -931,6 +935,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $application->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -945,6 +950,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); } } @@ -972,6 +978,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -987,6 +994,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; } @@ -1003,6 +1011,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1016,6 +1025,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); } } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index b47e57047..f65b62698 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1265,24 +1265,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $preview->domain_port_overrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->save(); } } } @@ -1359,6 +1351,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $previewForPorts = $isPullRequest + ? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first()) + : null; + $domainPortOverrides = $isPullRequest + ? ($previewForPorts?->domain_port_overrides ?? []) + : ($originalResource->domain_port_overrides ?? []); + $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; + $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1374,8 +1374,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -1389,9 +1391,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -1405,8 +1409,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -1418,9 +1424,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -1849,11 +1857,7 @@ function serviceParser(Service $resource): Collection // Only save fqdn to ServiceApplication, not ServiceDatabase if ($isServiceApplication && is_null($savedService->fqdn)) { // Save URL (with scheme) to database, not FQDN - if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) { - $savedService->fqdn = $urlWithPort; - } else { - $savedService->fqdn = $urlWithPort; - } + $savedService->fqdn = $url; $savedService->save(); } @@ -2636,6 +2640,9 @@ function serviceParser(Service $resource): Collection $redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($originalResource, 'redirect') : 'both'; + $onlyPort = $originalResource instanceof ServiceApplication + ? ($originalResource->getRequiredPort() ?? $predefinedPort) + : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -2651,6 +2658,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2666,7 +2675,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2682,6 +2693,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2695,7 +2708,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f4dd3d185..9f18c466d 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3103,6 +3103,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($savedService, 'redirect') : 'both'; + $domainPortOverrides = $savedService instanceof ServiceApplication + ? ($savedService->domain_port_overrides ?? []) + : []; + $onlyPort = $savedService instanceof ServiceApplication + ? ($savedService->getRequiredPort() ?? $predefinedPort) + : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3115,8 +3121,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -3130,8 +3138,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -3145,8 +3156,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -3158,8 +3171,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -3858,6 +3874,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = str($fqdns)->explode(','); if ($pull_request_id !== 0) { $preview = $resource->previews()->find($preview_id); + if (! $preview) { + try { + $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); + } catch (ModelNotFoundException) { + throw new RuntimeException('Preview not found.'); + } + } $docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []; if (count($docker_compose_domains) > 0) { $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); @@ -3867,22 +3890,20 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pull_request_id, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $generatedOverrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->domain_port_overrides = array_replace( + $preview->domain_port_overrides ?? [], + $generatedOverrides, + ); + $preview->save(); } } $noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains(); @@ -3891,6 +3912,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $domainPortOverrides = $pull_request_id === 0 + ? ($resource->domain_port_overrides ?? []) + : ($preview?->domain_port_overrides ?? []); + $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; + $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3904,8 +3930,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3920,8 +3948,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3937,8 +3967,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); $serviceLabels = $serviceLabels->merge( @@ -3951,8 +3983,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); } diff --git a/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php new file mode 100644 index 000000000..c51551e79 --- /dev/null +++ b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php new file mode 100644 index 000000000..f208c5865 --- /dev/null +++ b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php new file mode 100644 index 000000000..80fc8a061 --- /dev/null +++ b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index 128f2f262..b80085b9d 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -366,4 +366,48 @@ class="icon-button shrink-0" aria-label="Close"> + + @if ($showPortWarningModal) +
+ +
+ @endif diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index d301b55b5..6c455c197 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -398,7 +398,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endif @if ($buildPack !== 'dockercompose') - + @php + $applicationDomainsUrl = route('project.application.domains', [ + 'project_uuid' => $application->environment->project->uuid, + 'environment_uuid' => $application->environment->uuid, + 'application_uuid' => $application->uuid, + ]); + $portsExposesDomainHint = "You can also set a different internal port for each domain on the Domains page."; + @endphp + @if ($this->detectedPortInfo) @if ($this->detectedPortInfo['isEmpty'])
+
@if ($isStatic || $buildPack === 'static') @else @if ($application->settings->is_container_label_readonly_enabled === false) @else @endif @endif +

+ You can also set an internal port per domain on + Domains. +

+
@if (!$application->destination->server->isSwarm()) {{ $row['url'] }} + @if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0) + + Internal port {{ $row['internal_port'] }} + + @else + + No internal port + + @endif @endif @if ($isSuggested && ! empty($row['suggestion_label'])) {{ $row['suggestion_label'] }} diff --git a/resources/views/livewire/project/application/preview-domains.blade.php b/resources/views/livewire/project/application/preview-domains.blade.php index 766d0f64a..2a131e43c 100644 --- a/resources/views/livewire/project/application/preview-domains.blade.php +++ b/resources/views/livewire/project/application/preview-domains.blade.php @@ -76,6 +76,17 @@ {{ $row['url'] }} + @if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0) + + Internal port {{ $row['internal_port'] }} + + @else + + No internal port + + @endif @if (filled($row['service'])) {{ $row['service'] }} @endif @@ -149,4 +160,48 @@ class="application-settings-form application-settings-section relative w-full ma
+ + @if ($showPortWarningModal) +
+ +
+ @endif diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 9a3a59482..a02d5ae75 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -296,7 +296,7 @@ class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl" style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
-

Remove required port?

+

Use a different port?