diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php
index 0a20deae5..22c48aa89 100644
--- a/app/Actions/Server/ValidateServer.php
+++ b/app/Actions/Server/ValidateServer.php
@@ -30,7 +30,8 @@ public function handle(Server $server)
]);
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.
Error: '.$error.'
';
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.
Error: '.$sanitizedError.'
';
$server->update([
'validation_logs' => $this->error,
]);
diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
index a3eab4dca..d4d29af20 100644
--- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php
+++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
@@ -4,6 +4,7 @@
use App\Jobs\ServerLimitCheckJob;
use App\Models\Team;
+use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class UpdateSubscriptionQuantity
@@ -42,6 +43,7 @@ public function fetchPricePreview(Team $team, int $quantity): array
}
$currency = strtoupper($item->price->currency ?? 'usd');
+ $billingInterval = $item->price->recurring->interval ?? 'month';
// Upcoming invoice gives us the prorated amount due now
$upcomingInvoice = $this->stripe->invoices->upcoming([
@@ -99,6 +101,7 @@ public function fetchPricePreview(Team $team, int $quantity): array
'tax_description' => $taxDescription,
'quantity' => $quantity,
'currency' => $currency,
+ 'billing_interval' => $billingInterval,
],
];
} catch (\Exception $e) {
@@ -184,7 +187,7 @@ public function execute(Team $team, int $quantity): array
\Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
return ['success' => true, 'error' => null];
- } catch (\Stripe\Exception\InvalidRequestException $e) {
+ } catch (InvalidRequestException $e) {
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php
index acc6dc2f9..7daa6ba28 100644
--- a/app/Console/Commands/Dev.php
+++ b/app/Console/Commands/Dev.php
@@ -30,32 +30,32 @@ public function init()
// Generate APP_KEY if not exists
if (empty(config('app.key'))) {
- echo "Generating APP_KEY.\n";
+ echo " INFO Generating APP_KEY.\n";
Artisan::call('key:generate');
}
// Generate STORAGE link if not exists
if (! file_exists(public_path('storage'))) {
- echo "Generating STORAGE link.\n";
+ echo " INFO Generating storage link.\n";
Artisan::call('storage:link');
}
// Seed database if it's empty
$settings = InstanceSettings::find(0);
if (! $settings) {
- echo "Initializing instance, seeding database.\n";
+ echo " INFO Initializing instance, seeding database.\n";
Artisan::call('migrate --seed');
} else {
- echo "Instance already initialized.\n";
+ echo " INFO Instance already initialized.\n";
}
// Clean up stuck jobs and stale locks on development startup
try {
- echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
+ echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n";
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
- echo "Redis cleanup completed.\n";
+ echo " INFO Redis cleanup completed.\n";
} catch (\Throwable $e) {
- echo "Error in cleanup:redis: {$e->getMessage()}\n";
+ echo " ERROR Redis cleanup failed: {$e->getMessage()}\n";
}
try {
@@ -66,10 +66,10 @@ public function init()
]);
if ($updatedTaskCount > 0) {
- echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
+ echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n";
}
} catch (\Throwable $e) {
- echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
+ echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
@@ -80,10 +80,10 @@ public function init()
]);
if ($updatedBackupCount > 0) {
- echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
+ echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n";
}
} catch (\Throwable $e) {
- echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
+ echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n";
}
CheckHelperImageJob::dispatch();
diff --git a/app/Console/Commands/Horizon.php b/app/Console/Commands/Horizon.php
deleted file mode 100644
index d3e35ca5a..000000000
--- a/app/Console/Commands/Horizon.php
+++ /dev/null
@@ -1,23 +0,0 @@
-info('Horizon is enabled on this server.');
- $this->call('horizon');
- exit(0);
- } else {
- exit(0);
- }
- }
-}
diff --git a/app/Console/Commands/Nightwatch.php b/app/Console/Commands/Nightwatch.php
deleted file mode 100644
index 40fd86a81..000000000
--- a/app/Console/Commands/Nightwatch.php
+++ /dev/null
@@ -1,22 +0,0 @@
-info('Nightwatch is enabled on this server.');
- $this->call('nightwatch:agent');
- }
-
- exit(0);
- }
-}
diff --git a/app/Console/Commands/Scheduler.php b/app/Console/Commands/Scheduler.php
deleted file mode 100644
index ee64368c3..000000000
--- a/app/Console/Commands/Scheduler.php
+++ /dev/null
@@ -1,23 +0,0 @@
-info('Scheduler is enabled on this server.');
- $this->call('schedule:work');
- exit(0);
- } else {
- exit(0);
- }
- }
-}
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index 288904471..ee8cf2797 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -45,7 +45,8 @@ public function handle(): void
// Validate connection
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if (! $uptime) {
- $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.
Error: '.$error;
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.
Error: '.$sanitizedError;
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
@@ -197,7 +198,7 @@ public function handle(): void
]);
$this->server->update([
- 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
+ 'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'),
'is_validating' => false,
]);
}
diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php
index fd55717fa..810b95ed4 100644
--- a/app/Livewire/Server/PrivateKey/Show.php
+++ b/app/Livewire/Server/PrivateKey/Show.php
@@ -63,7 +63,8 @@ public function checkConnection()
$this->dispatch('success', 'Server is reachable.');
$this->dispatch('refreshServerShow');
} else {
- $this->dispatch('error', 'Server is not reachable.
Check this documentation for further help.
Error: '.$error);
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->dispatch('error', 'Server is not reachable.
Check this documentation for further help.
Error: '.$sanitizedError);
return;
}
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 198d823b9..59ca4cd36 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -89,7 +89,8 @@ public function validateConnection()
$this->authorize('update', $this->server);
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.
Error: '.$error.'
';
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.
Error: '.$sanitizedError.'
';
$this->server->update([
'validation_logs' => $this->error,
]);
diff --git a/app/Models/Server.php b/app/Models/Server.php
index b3dcf6353..0ae524f49 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -267,6 +267,13 @@ public static function flushIdentityMap(): void
use HasSafeStringAttribute;
+ public function setValidationLogsAttribute($value): void
+ {
+ $this->attributes['validation_logs'] = $value !== null
+ ? \Stevebauman\Purify\Facades\Purify::config('validation_logs')->clean($value)
+ : null;
+ }
+
public function type()
{
return 'server';
diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php
index 179c8d948..511818e21 100644
--- a/app/Notifications/TransactionalEmails/ResetPassword.php
+++ b/app/Notifications/TransactionalEmails/ResetPassword.php
@@ -67,9 +67,12 @@ protected function resetUrl($notifiable)
return call_user_func(static::$createUrlCallback, $notifiable, $this->token);
}
- return url(route('password.reset', [
+ $path = route('password.reset', [
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
- ], false));
+ ], false);
+
+ // Use server-side config (FQDN / public IP) instead of request host
+ return rtrim(base_url(), '/').$path;
}
}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 84472a07e..cd773f6a9 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -16,6 +16,7 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -28,8 +29,10 @@
use App\Models\User;
use Carbon\CarbonImmutable;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Process\Pool;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
@@ -49,10 +52,14 @@
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token\Builder;
+use Livewire\Component;
+use Nubs\RandomNameGenerator\All;
+use Nubs\RandomNameGenerator\Alliteration;
use phpseclib3\Crypt\EC;
use phpseclib3\Crypt\RSA;
use Poliander\Cron\CronExpression;
use PurplePixie\PhpDns\DNSQuery;
+use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
@@ -116,7 +123,7 @@ function sanitize_string(?string $input = null): ?string
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
* @return string The validated input (unchanged if valid)
*
- * @throws \Exception If dangerous characters are detected
+ * @throws Exception If dangerous characters are detected
*/
function validateShellSafePath(string $input, string $context = 'path'): string
{
@@ -138,7 +145,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
// Check for dangerous characters
foreach ($dangerousChars as $char => $description) {
if (str_contains($input, $char)) {
- throw new \Exception(
+ throw new Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
'Shell metacharacters are not allowed for security reasons.'
);
@@ -160,7 +167,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
* @param string $input The databases_to_backup string
* @return string The validated input
*
- * @throws \Exception If any component contains dangerous characters
+ * @throws Exception If any component contains dangerous characters
*/
function validateDatabasesBackupInput(string $input): string
{
@@ -211,7 +218,7 @@ function validateDatabasesBackupInput(string $input): string
* @param string $context Descriptive name for error messages
* @return string The validated input (trimmed)
*
- * @throws \Exception If the input contains disallowed characters
+ * @throws Exception If the input contains disallowed characters
*/
function validateGitRef(string $input, string $context = 'git ref'): string
{
@@ -223,12 +230,12 @@ function validateGitRef(string $input, string $context = 'git ref'): string
// Must not start with a hyphen (git flag injection)
if (str_starts_with($input, '-')) {
- throw new \Exception("Invalid {$context}: must not start with a hyphen.");
+ throw new Exception("Invalid {$context}: must not start with a hyphen.");
}
// Allow only alphanumeric characters, dots, hyphens, underscores, and slashes
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) {
- throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
+ throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
}
return $input;
@@ -282,7 +289,7 @@ function refreshSession(?Team $team = null): void
});
session(['currentTeam' => $team]);
}
-function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null)
+function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null)
{
if ($error instanceof TooManyRequestsException) {
if (isset($livewire)) {
@@ -299,7 +306,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
return 'Duplicate entry found. Please use a different name.';
}
- if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
+ if ($error instanceof ModelNotFoundException) {
abort(404);
}
@@ -329,7 +336,7 @@ function get_latest_sentinel_version(): string
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
- } catch (\Throwable) {
+ } catch (Throwable) {
return '0.0.0';
}
}
@@ -339,7 +346,7 @@ function get_latest_version_of_coolify(): string
$versions = get_versions_data();
return data_get($versions, 'coolify.v4.version', '0.0.0');
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
return '0.0.0';
}
@@ -347,9 +354,9 @@ function get_latest_version_of_coolify(): string
function generate_random_name(?string $cuid = null): string
{
- $generator = new \Nubs\RandomNameGenerator\All(
+ $generator = new All(
[
- new \Nubs\RandomNameGenerator\Alliteration,
+ new Alliteration,
]
);
if (is_null($cuid)) {
@@ -448,7 +455,7 @@ function getFqdnWithoutPort(string $fqdn)
$path = $url->getPath();
return "$scheme://$host$path";
- } catch (\Throwable) {
+ } catch (Throwable) {
return $fqdn;
}
}
@@ -478,13 +485,13 @@ function base_url(bool $withPort = true): string
}
if ($settings->public_ipv6) {
if ($withPort) {
- return "http://$settings->public_ipv6:$port";
+ return "http://[$settings->public_ipv6]:$port";
}
- return "http://$settings->public_ipv6";
+ return "http://[$settings->public_ipv6]";
}
- return url('/');
+ return config('app.url');
}
function isSubscribed()
@@ -537,21 +544,21 @@ function validate_cron_expression($expression_to_validate): bool
* Even if the job runs minutes late, it still catches the missed cron window.
* Without a dedupKey, falls back to a simple isDue() check.
*/
-function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
+function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool
{
- $cron = new \Cron\CronExpression($frequency);
- $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
+ $cron = new Cron\CronExpression($frequency);
+ $executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone);
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
- $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+ $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
$shouldFire = $lastDispatched === null
? $cron->isDue($executionTime)
- : $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
+ : $previousDue->gt(Carbon::parse($lastDispatched));
// Always write: seeds on first miss, refreshes on dispatch.
// 30-day static TTL covers all intervals; orphan keys self-clean.
@@ -932,7 +939,7 @@ function get_service_templates(bool $force = false): Collection
$services = $response->json();
return collect($services);
- } catch (\Throwable) {
+ } catch (Throwable) {
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
return collect(json_decode($services))->sortKeys();
@@ -955,7 +962,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
// ServiceDatabase has a different relationship path: service->environment->project->team_id
- if ($resource instanceof \App\Models\ServiceDatabase) {
+ if ($resource instanceof ServiceDatabase) {
if ($resource->service?->environment?->project?->team_id === $teamId) {
return $resource;
}
@@ -1081,7 +1088,7 @@ function generateGitManualWebhook($resource, $type)
if ($resource->source_id !== 0 && ! is_null($resource->source_id)) {
return null;
}
- if ($resource->getMorphClass() === \App\Models\Application::class) {
+ if ($resource->getMorphClass() === Application::class) {
$baseUrl = base_url();
return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual";
@@ -1102,11 +1109,11 @@ function sanitizeLogsForExport(string $text): string
function getTopLevelNetworks(Service|Application $resource)
{
- if ($resource->getMorphClass() === \App\Models\Service::class) {
+ if ($resource->getMorphClass() === Service::class) {
if ($resource->docker_compose_raw) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
$resource->uuid => [
@@ -1169,10 +1176,10 @@ function getTopLevelNetworks(Service|Application $resource)
return $topLevelNetworks->keys();
}
- } elseif ($resource->getMorphClass() === \App\Models\Application::class) {
+ } elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
$resource->uuid => [
@@ -1479,7 +1486,7 @@ function validateDNSEntry(string $fqdn, Server $server)
$ip = $server->ip;
}
$found_matching_ip = false;
- $type = \PurplePixie\PhpDns\DNSTypes::NAME_A;
+ $type = DNSTypes::NAME_A;
foreach ($dns_servers as $dns_server) {
try {
$query = new DNSQuery($dns_server);
@@ -1500,7 +1507,7 @@ function validateDNSEntry(string $fqdn, Server $server)
}
}
}
- } catch (\Exception) {
+ } catch (Exception) {
}
}
@@ -1682,7 +1689,7 @@ function get_public_ips()
}
InstanceSettings::get()->update(['public_ipv4' => $ipv4]);
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
echo "Error: {$e->getMessage()}\n";
}
try {
@@ -1697,7 +1704,7 @@ function get_public_ips()
}
InstanceSettings::get()->update(['public_ipv6' => $ipv6]);
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
echo "Error: {$e->getMessage()}\n";
}
}
@@ -1795,15 +1802,15 @@ function customApiValidator(Collection|array $item, array $rules)
}
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
{
- if ($resource->getMorphClass() === \App\Models\Service::class) {
+ if ($resource->getMorphClass() === Service::class) {
if ($resource->docker_compose_raw) {
// Extract inline comments from raw YAML before Symfony parser discards them
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception $e) {
- throw new \RuntimeException($e->getMessage());
+ } catch (Exception $e) {
+ throw new RuntimeException($e->getMessage());
}
$allServices = get_service_templates();
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
@@ -2567,10 +2574,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} else {
return collect([]);
}
- } elseif ($resource->getMorphClass() === \App\Models\Application::class) {
+ } elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception) {
+ } catch (Exception) {
return;
}
$server = $resource->destination->server;
@@ -3332,7 +3339,7 @@ function isAssociativeArray($array)
}
if (! is_array($array)) {
- throw new \InvalidArgumentException('Input must be an array or a Collection.');
+ throw new InvalidArgumentException('Input must be an array or a Collection.');
}
if ($array === []) {
@@ -3448,7 +3455,7 @@ function wireNavigate(): string
// Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
- } catch (\Exception $e) {
+ } catch (Exception $e) {
return 'wire:navigate.hover';
}
}
@@ -3457,13 +3464,13 @@ function wireNavigate(): string
* Redirect to a named route with SPA navigation support.
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
*/
-function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed
+function redirectRoute(Component $component, string $name, array $parameters = []): mixed
{
$navigate = true;
try {
$navigate = instanceSettings()->is_wire_navigate_enabled ?? true;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$navigate = true;
}
@@ -3505,7 +3512,7 @@ function loadConfigFromGit(string $repository, string $branch, string $base_dire
]);
try {
return instant_remote_process($commands, $server);
- } catch (\Exception) {
+ } catch (Exception) {
// continue
}
}
@@ -3636,8 +3643,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
// If this happens, the user may have provided an HTTP URL when they needed an SSH one
// Let's try and fix that for known Git providers
switch ($source->getMorphClass()) {
- case \App\Models\GithubApp::class:
- case \App\Models\GitlabApp::class:
+ case GithubApp::class:
+ case GitlabApp::class:
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
$providerInfo['port'] = $source->custom_port;
$providerInfo['user'] = $source->custom_user;
@@ -3915,10 +3922,10 @@ function shouldSkipPasswordConfirmation(): bool
* - User has no password (OAuth users)
*
* @param mixed $password The password to verify (may be array if skipped by frontend)
- * @param \Livewire\Component|null $component Optional Livewire component to add errors to
+ * @param Component|null $component Optional Livewire component to add errors to
* @return bool True if verification passed (or skipped), false if password is incorrect
*/
-function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
+function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool
{
// Skip if password confirmation should be skipped
if (shouldSkipPasswordConfirmation()) {
@@ -3941,17 +3948,17 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
* Extract hard-coded environment variables from docker-compose YAML.
*
* @param string $dockerComposeRaw Raw YAML content
- * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name
+ * @return Collection Collection of arrays with: key, value, comment, service_name
*/
-function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
+function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection
{
if (blank($dockerComposeRaw)) {
return collect([]);
}
try {
- $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
- } catch (\Exception $e) {
+ $yaml = Yaml::parse($dockerComposeRaw);
+ } catch (Exception $e) {
// Malformed YAML - return empty collection
return collect([]);
}
@@ -4100,7 +4107,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
if (is_null($id)) {
continue;
}
- $found = \App\Models\SharedEnvironmentVariable::where('type', $type)
+ $found = SharedEnvironmentVariable::where('type', $type)
->where('key', $variable)
->where('team_id', $resource->team()->id)
->where("{$type}_id", $id)
diff --git a/config/purify.php b/config/purify.php
index 66dbbb568..a5dcabb92 100644
--- a/config/purify.php
+++ b/config/purify.php
@@ -49,6 +49,17 @@
'AutoFormat.RemoveEmpty' => false,
],
+ 'validation_logs' => [
+ 'Core.Encoding' => 'utf-8',
+ 'HTML.Doctype' => 'HTML 4.01 Transitional',
+ 'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]',
+ 'HTML.ForbiddenElements' => '',
+ 'CSS.AllowedProperties' => '',
+ 'AutoFormat.AutoParagraph' => false,
+ 'AutoFormat.RemoveEmpty' => false,
+ 'Attr.AllowedFrameTargets' => ['_blank'],
+ ],
+
],
/*
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run
index ada19b3a3..dbc472d06 100644
--- a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run
+++ b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run
@@ -1,12 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:horizon
-}
+if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Horizon is disabled, sleeping."
+ exec sleep infinity
+fi
+echo " INFO Horizon is enabled, starting..."
+exec php artisan horizon
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
index 1166ccd08..ee46dba7e 100644
--- a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
+++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
@@ -1,12 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:nightwatch
-}
+if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
+ echo " INFO Nightwatch is enabled, starting..."
+ exec php artisan nightwatch:agent
+fi
+echo " INFO Nightwatch is disabled, sleeping."
+exec sleep infinity
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run
index b81a44833..bfa44c7e3 100644
--- a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run
+++ b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run
@@ -1,13 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:scheduler
-}
-
+if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Scheduler is disabled, sleeping."
+ exec sleep infinity
+fi
+echo " INFO Scheduler is enabled, starting..."
+exec php artisan schedule:work
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run
index be6647607..dbc472d06 100644
--- a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run
+++ b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run
@@ -1,11 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:horizon
-}
+if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Horizon is disabled, sleeping."
+ exec sleep infinity
+fi
+
+echo " INFO Horizon is enabled, starting..."
+exec php artisan horizon
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
index 80d73eadb..ee46dba7e 100644
--- a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
+++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
@@ -1,11 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:nightwatch
-}
+if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
+ echo " INFO Nightwatch is enabled, starting..."
+ exec php artisan nightwatch:agent
+fi
+
+echo " INFO Nightwatch is disabled, sleeping."
+exec sleep infinity
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run
index a2ecb0a73..bfa44c7e3 100644
--- a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run
+++ b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run
@@ -1,10 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:scheduler
-}
+
+if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Scheduler is disabled, sleeping."
+ exec sleep infinity
+fi
+
+echo " INFO Scheduler is enabled, starting..."
+exec php artisan schedule:work
diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php
index 6fba0ed83..aa129043b 100644
--- a/resources/views/livewire/subscription/actions.blade.php
+++ b/resources/views/livewire/subscription/actions.blade.php
@@ -160,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}
+ Total / month
diff --git a/tests/Feature/ResetPasswordUrlTest.php b/tests/Feature/ResetPasswordUrlTest.php
new file mode 100644
index 000000000..7e940fc71
--- /dev/null
+++ b/tests/Feature/ResetPasswordUrlTest.php
@@ -0,0 +1,187 @@
+invoke($notification, $notifiable);
+}
+
+it('generates reset URL using configured FQDN, not request host', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
+ );
+ Once::flush();
+
+ $user = User::factory()->create();
+ $notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
+
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toStartWith('https://coolify.example.com/')
+ ->toContain('test-token-abc')
+ ->toContain(urlencode($user->email))
+ ->not->toContain('localhost');
+});
+
+it('generates reset URL using public IP when no FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => null, 'public_ipv4' => '65.21.3.91']
+ );
+ Once::flush();
+
+ $user = User::factory()->create();
+ $notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
+
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toContain('65.21.3.91')
+ ->toContain('test-token-abc')
+ ->not->toContain('evil.com');
+});
+
+it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
+ );
+ Once::flush();
+
+ // Simulate a request with a spoofed X-Forwarded-Host header
+ $user = User::factory()->create();
+
+ $this->withHeaders([
+ 'X-Forwarded-Host' => 'evil.com',
+ ])->get('/');
+
+ $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toStartWith('https://coolify.example.com/')
+ ->toContain('poisoned-token')
+ ->not->toContain('evil.com');
+});
+
+it('is immune to X-Forwarded-Host header poisoning when using IP only', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => null, 'public_ipv4' => '65.21.3.91']
+ );
+ Once::flush();
+
+ $user = User::factory()->create();
+
+ $this->withHeaders([
+ 'X-Forwarded-Host' => 'evil.com',
+ ])->get('/');
+
+ $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toContain('65.21.3.91')
+ ->toContain('poisoned-token')
+ ->not->toContain('evil.com');
+});
+
+it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
+ );
+ Once::flush();
+
+ $user = User::factory()->create();
+ $notification = new ResetPassword('ipv6-token', isTransactionalEmail: false);
+
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toContain('[2001:db8::1]')
+ ->toContain('ipv6-token')
+ ->toContain(urlencode($user->email));
+});
+
+it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
+ );
+ Once::flush();
+
+ $user = User::factory()->create();
+
+ $this->withHeaders([
+ 'X-Forwarded-Host' => 'evil.com',
+ ])->get('/');
+
+ $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toContain('[2001:db8::1]')
+ ->toContain('poisoned-token')
+ ->not->toContain('evil.com');
+});
+
+it('uses APP_URL fallback when no FQDN or public IPs are configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null]
+ );
+ Once::flush();
+
+ config(['app.url' => 'http://my-coolify.local']);
+
+ $user = User::factory()->create();
+
+ $this->withHeaders([
+ 'X-Forwarded-Host' => 'evil.com',
+ ])->get('/');
+
+ $notification = new ResetPassword('fallback-token', isTransactionalEmail: false);
+ $url = callResetUrl($notification, $user);
+
+ expect($url)
+ ->toStartWith('http://my-coolify.local/')
+ ->toContain('fallback-token')
+ ->not->toContain('evil.com');
+});
+
+it('generates a valid route path in the reset URL', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+ Once::flush();
+
+ $user = User::factory()->create();
+ $notification = new ResetPassword('my-token', isTransactionalEmail: false);
+
+ $url = callResetUrl($notification, $user);
+
+ // Should contain the password reset route path with token and email
+ expect($url)
+ ->toContain('/reset-password/')
+ ->toContain('my-token')
+ ->toContain(urlencode($user->email));
+});
diff --git a/tests/Feature/ServerValidationXssTest.php b/tests/Feature/ServerValidationXssTest.php
new file mode 100644
index 000000000..ba8e6fcae
--- /dev/null
+++ b/tests/Feature/ServerValidationXssTest.php
@@ -0,0 +1,75 @@
+create();
+ $this->team = Team::factory()->create();
+ $user->teams()->attach($this->team);
+ $this->actingAs($user);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+});
+
+it('strips dangerous HTML from validation_logs via mutator', function () {
+ $xssPayload = '
';
+ $this->server->update(['validation_logs' => $xssPayload]);
+ $this->server->refresh();
+
+ expect($this->server->validation_logs)->not->toContain('
and($this->server->validation_logs)->not->toContain('onerror');
+});
+
+it('strips script tags from validation_logs', function () {
+ $xssPayload = '';
+ $this->server->update(['validation_logs' => $xssPayload]);
+ $this->server->refresh();
+
+ expect($this->server->validation_logs)->not->toContain('