chore: merge main into next (#11678)

This commit is contained in:
Andras Bacsai
2026-09-08 10:11:56 +02:00
committed by GitHub
186 changed files with 7373 additions and 1309 deletions
-1
View File
@@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444
BUNNY_API_KEY=
# For asset uploads
BUNNY_STORAGE_API_KEY=
AVATAR_CDN_URL=
-1
View File
@@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify
PUSHER_APP_ID=coolify
PUSHER_APP_KEY=coolify
PUSHER_APP_SECRET=coolify
AVATAR_CDN_URL=
+2 -2
View File
@@ -1,8 +1,8 @@
name: Sync main to next
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
+15 -2
View File
@@ -520,7 +520,10 @@ Do not restore the old full-width footer.
Deferred fields in one Livewire component use one floating unsaved bar and one
submit action. Do not add a separate “Save configuration” button to every
card. Selectors that are safe to persist independently should use the existing
instant-save pattern.
instant-save pattern. When those requests share a component with a modal draft,
pass the unsaved bar a `dirty` Alpine expression comparing that draft with its
initial values, so unrelated saves do not hide pending changes. Mount modal save
bars only while the modal is open to avoid inactive keyboard shortcuts.
---
@@ -567,6 +570,15 @@ that hide secondary columns before allowing horizontal overflow.
---
### Domain rows on mobile
Domain tables become compact summary cards below 600px. Keep the public URL on
its own line, followed by a short routing summary such as `HTTP → HTTPS · Port
80 · Noindex`. Put DNS status and the existing icon actions on the final row.
Do not squeeze desktop label/value columns into a mobile card or move settings
behind an overflow menu. Long domains wrap, and icon actions retain 40px touch
targets.
## 8. Modals, confirmations, and toasts
### Modals
@@ -626,8 +638,9 @@ Current toast behavior:
- Reicon status tile for success, info, warning, danger, or default;
- title plus optional description;
- dismiss and copy-details actions;
- up to four stacked notifications;
- normally up to four stacked notifications, without evicting persistent notices;
- four-second dismissal, paused while hovered;
- `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button;
- support for all six screen positions and sanitized custom HTML.
Do not bring back the old oversized dark rectangle.
+2 -2
View File
@@ -44,8 +44,6 @@ class StopApplication
$commands = [dockerStopCommand($timeout, $containerName, $server)];
if ($removeContainers) {
$commands[] = "docker rm -f $containerName";
} else {
array_unshift($commands, "docker update --restart=no $containerName");
}
instant_remote_process(command: $commands, server: $server, throwError: false);
@@ -78,5 +76,7 @@ class StopApplication
$application->update($status);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
return null;
}
}
@@ -20,8 +20,6 @@ class StopApplicationPreview
$commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)];
if ($removeContainer) {
$commands[] = "docker rm -f $containerName";
} else {
array_unshift($commands, "docker update --restart=no $containerName");
}
instant_remote_process($commands, $server, false);
}
+5 -2
View File
@@ -32,7 +32,11 @@ class StartDatabase
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$database->resetRestartLimit();
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
$activity = activity()
->withProperties([
@@ -49,7 +53,6 @@ class StartDatabase
if ($activity === null) {
return 'Database start could not be queued because activity logging is disabled.';
}
DatabaseStartJob::dispatch(
+5 -3
View File
@@ -32,7 +32,11 @@ class StopDatabase
// Reset restart tracking when database is manually stopped
$database->update(['status' => 'exited']);
if ($resetRestartCount) {
$database->resetRestartLimit();
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
if ($dockerCleanup) {
@@ -58,8 +62,6 @@ class StopDatabase
$commands = [dockerStopCommand($timeout, $containerName, $server)];
if ($removeContainer) {
$commands[] = "docker rm -f $containerName";
} else {
array_unshift($commands, "docker update --restart=no $containerName");
}
instant_remote_process(command: $commands, server: $server, throwError: false);
}
+10 -10
View File
@@ -5,7 +5,6 @@ namespace App\Actions\Docker;
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabase;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Shared\ComplexStatusCheck;
@@ -249,9 +248,12 @@ class GetContainersStatus
$database->update($updateData);
if ($database->trackRestartCount((int) $restartCount)) {
StopDatabase::dispatch($database, false, false, false);
$database->team()?->notify(new ApplicationRestartLimitReached($database));
if ($restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if ($isPublic) {
@@ -357,7 +359,9 @@ class GetContainersStatus
continue;
}
if (! $exitedService->stoppedAfterRestartLimit()) {
if ($exitedService instanceof ServiceDatabase) {
$exitedService->update(['status' => 'exited']);
} elseif (! $exitedService->stoppedAfterRestartLimit()) {
$exitedService->update([
'status' => 'exited',
'restart_count' => 0,
@@ -424,9 +428,6 @@ class GetContainersStatus
$notRunningDatabases = $databases->pluck('id')->diff($foundDatabases);
foreach ($notRunningDatabases as $database) {
$database = $databases->where('id', $database)->first();
if ($database->stoppedAfterRestartLimit()) {
continue;
}
if (str($database->status)->startsWith('exited')) {
continue;
}
@@ -442,7 +443,6 @@ class GetContainersStatus
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
'restart_limit_reached' => false,
]);
// Stop proxy if database was public
@@ -582,7 +582,7 @@ class GetContainersStatus
$restartCount = isset($this->serviceContainerRestartCounts)
? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0)
: 0;
if ($subResource->trackRestartCount($restartCount)) {
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
-1
View File
@@ -25,7 +25,6 @@ class StartService
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$service->applications()->get()->each->resetRestartLimit();
$service->databases()->get()->each->resetRestartLimit();
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
-1
View File
@@ -55,7 +55,6 @@ class StopService
});
$dbs->each(function ($database): void {
$database->update(['status' => 'exited']);
$database->resetRestartLimit();
});
if ($deleteConnectedNetworks) {
@@ -22,15 +22,12 @@ class StopServiceApplication
if ($removeContainer) {
$commands = ["docker rm -f {$containerName}"];
} else {
$commands = [
"docker update --restart=no {$containerName}",
"docker stop {$containerName}",
];
$commands = ["docker stop {$containerName}"];
}
instant_remote_process($commands, $server, throwError: ! $removeContainer);
$serviceApplication->update(['status' => 'exited']);
if ($resetRestartCount) {
if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) {
$serviceApplication->resetRestartLimit();
}
ServiceStatusChanged::dispatch($service->environment->project->team->id);
@@ -0,0 +1,221 @@
<?php
namespace App\Actions\Stripe;
use App\Exceptions\CheckoutUnavailableException;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\StripeClient;
use Throwable;
class CreateCheckoutSession
{
private const BLOCKING_SUBSCRIPTION_STATUSES = [
'active',
'incomplete',
'past_due',
'paused',
'trialing',
'unpaid',
];
private const RECOVERABLE_SUBSCRIPTION_STATUSES = [
'incomplete',
'past_due',
'paused',
'unpaid',
];
public function __construct(private ?StripeClient $stripe = null)
{
$this->stripe ??= app(StripeClient::class);
}
public static function lockKey(int $teamId): string
{
return "stripe-checkout:team:{$teamId}";
}
public function execute(Team $team, User $user, string $priceId): object
{
$lock = Cache::lock(self::lockKey($team->id), 30);
if (! $lock->get()) {
throw new CheckoutUnavailableException('A subscription checkout is already being created for this team.');
}
$previousMaxNetworkRetries = Stripe::getMaxNetworkRetries();
Stripe::setMaxNetworkRetries(2);
try {
return $this->createOrReuseSession($team, $user, $priceId);
} finally {
Stripe::setMaxNetworkRetries($previousMaxNetworkRetries);
$lock->release();
}
}
private function createOrReuseSession(Team $team, User $user, string $priceId): object
{
$subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]);
$customerId = $subscription->stripe_customer_id;
if (! $customerId) {
$customer = $this->stripe->customers->create([
'email' => $user->email,
'metadata' => [
'team_id' => $team->id,
],
], [
'idempotency_key' => "coolify-team-{$team->id}-customer",
]);
$customerId = $customer->id;
$subscription->stripe_customer_id = $customerId;
$subscription->save();
Log::info('Stripe customer assigned for subscription checkout.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
]);
}
$blockingSubscription = null;
foreach ($this->stripe->subscriptions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'all',
])->autoPagingIterator() as $stripeSubscription) {
if (in_array($stripeSubscription->status, self::BLOCKING_SUBSCRIPTION_STATUSES, true)) {
$blockingSubscription = $stripeSubscription;
break;
}
}
$this->throwIfBlockingSubscription($team, $customerId, $blockingSubscription);
$sessions = $this->stripe->checkout->sessions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'open',
]);
$subscriptionSessions = collect($sessions->data)->filter(
fn (object $session): bool => ($session->mode ?? null) === 'subscription'
);
$openSession = $subscriptionSessions->first(
fn (object $session): bool => ($session->status ?? null) === 'open'
);
if ($openSession) {
$lineItems = $this->stripe->checkout->sessions->allLineItems($openSession->id);
if (count($lineItems->data) === 1 && data_get($lineItems, 'data.0.price.id') === $priceId) {
Log::info('Reusing pending Stripe subscription checkout.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_checkout_session_id' => $openSession->id,
'stripe_subscription_id' => $openSession->subscription ?? null,
]);
return $openSession;
}
$this->stripe->checkout->sessions->expire($openSession->id);
}
$session = $this->stripe->checkout->sessions->create([
'allow_promotion_codes' => true,
'billing_address_collection' => 'required',
'client_reference_id' => $user->id.':'.$team->id,
'customer' => $customerId,
'customer_update' => [
'name' => 'auto',
'address' => 'auto',
],
'line_items' => [[
'price' => $priceId,
'adjustable_quantity' => [
'enabled' => true,
'minimum' => 2,
],
'quantity' => 2,
]],
'tax_id_collection' => [
'enabled' => true,
],
'automatic_tax' => [
'enabled' => true,
],
'subscription_data' => [
'metadata' => [
'user_id' => $user->id,
'team_id' => $team->id,
],
],
'payment_method_collection' => 'if_required',
'mode' => 'subscription',
'expires_at' => now()->addMinutes(35)->timestamp,
'success_url' => route('dashboard', ['success' => true]),
'cancel_url' => route('subscription.index', ['cancelled' => true]),
]);
Log::info('Stripe subscription checkout created.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_checkout_session_id' => $session->id,
'stripe_subscription_id' => $session->subscription ?? null,
]);
return $session;
}
private function throwIfBlockingSubscription(Team $team, string $customerId, ?object $blockingSubscription): void
{
if (! $blockingSubscription) {
return;
}
Log::warning('Stripe subscription checkout blocked by existing subscription.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_subscription_id' => $blockingSubscription->id,
'stripe_subscription_status' => $blockingSubscription->status,
]);
$portalUrl = in_array($blockingSubscription->status, self::RECOVERABLE_SUBSCRIPTION_STATUSES, true)
? $this->billingPortalUrl($customerId)
: null;
throw new CheckoutUnavailableException(
$this->blockingSubscriptionMessage($blockingSubscription->status),
$portalUrl,
);
}
private function blockingSubscriptionMessage(string $status): string
{
return match ($status) {
'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.",
'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.",
'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.",
'paused' => "This team's subscription is paused. Resume it in the billing portal.",
default => 'Team already has an active subscription.',
};
}
private function billingPortalUrl(string $customerId): ?string
{
try {
$session = $this->stripe->billingPortal->sessions->create([
'customer' => $customerId,
'return_url' => route('subscription.show'),
]);
} catch (Throwable) {
return null;
}
return is_string($session->url ?? null) ? $session->url : null;
}
}
+5 -2
View File
@@ -5,6 +5,7 @@ namespace App\Console;
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckMissingDatabaseBackupsJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
@@ -47,11 +48,13 @@ class Kernel extends ConsoleKernel
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('cleanup:stucked-resources')
->daily()
->dailyAt('03:17')
->onOneServer()
->withoutOverlapping(60);
->withoutOverlapping(60)
->runInBackground();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
$this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs
@@ -0,0 +1,18 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class CheckoutUnavailableException extends Exception
{
public function __construct(
string $message = '',
public readonly ?string $billingPortalUrl = null,
int $code = 0,
?Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}
@@ -27,6 +27,7 @@ use App\Support\DomainPortOverrides;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -1455,7 +1456,9 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson);
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
$application->domain_port_overrides = $domainPortOverrides;
}
$repository_url_parsed = Url::fromString($request->git_repository);
$git_host = $repository_url_parsed->getHost();
@@ -1719,7 +1722,9 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson);
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
$application->domain_port_overrides = $domainPortOverrides;
}
$application->fqdn = $fqdn;
$application->git_repository = str($gitRepository)->trim()->toString();
@@ -1950,7 +1955,9 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson);
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
$application->domain_port_overrides = $domainPortOverrides;
}
$application->fqdn = $fqdn;
$application->private_key_id = $privateKey->id;
@@ -3369,7 +3376,12 @@ class ApplicationsController extends Controller
}
if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts(
$dockerComposeDomainsJson,
$application->domain_port_overrides,
);
data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson));
data_set($data, 'domain_port_overrides', $domainPortOverrides);
}
$requestHasNoindexDomains = $request->has('noindex_domains');
data_forget($data, 'noindex_domains');
@@ -6108,4 +6120,28 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Destination detached.']);
}
/**
* @param Collection<string, array{domain: ?string, redirect?: string}> $domains
* @param array<string, int|string>|null $existingOverrides
* @return array{Collection<string, array{domain: ?string, redirect?: string}>, ?array<string, int>}
*/
private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array
{
$allDomains = $domains
->pluck('domain')
->filter()
->implode(',');
$normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides);
$domains = $domains->map(function (array $entry): array {
$entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->implode(',');
return $entry;
});
return [$domains, $normalized['overrides']];
}
}
@@ -769,6 +769,7 @@ class DatabasesController extends Controller
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365, 'default' => 0],
],
),
)
@@ -805,7 +806,7 @@ class DatabasesController extends Controller
)]
public function create_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -833,6 +834,7 @@ class DatabasesController extends Controller
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
'missing_backup_notification_days' => 'integer|min:0|max:365',
]);
if ($validator->fails()) {
@@ -1025,6 +1027,7 @@ class DatabasesController extends Controller
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365],
],
),
)
@@ -1054,7 +1057,7 @@ class DatabasesController extends Controller
)]
public function update_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -1080,6 +1083,7 @@ class DatabasesController extends Controller
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
'missing_backup_notification_days' => 'integer|min:0|max:365',
]);
if ($validator->fails()) {
return response()->json([
@@ -4885,6 +4889,8 @@ class DatabasesController extends Controller
'id',
'created_at',
'updated_at',
'last_execution_at',
'missing_backup_notification_sent_at',
])->fill([
'uuid' => new_public_id(),
'database_id' => $newDatabase->id,
@@ -9,6 +9,7 @@ use App\Actions\Service\UpdateServiceApplicationFromApi;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -333,7 +334,7 @@ class ServiceApplicationsController extends Controller
];
$validationRules = [
'url' => 'nullable|string',
'url' => ValidationPatterns::applicationDomainRules(),
'noindex_domains' => 'sometimes|array|nullable',
'noindex_domains.*' => 'string',
'human_name' => 'nullable|string|max:255',
@@ -386,7 +386,7 @@ class ServicesController extends Controller
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
@@ -602,7 +602,7 @@ class ServicesController extends Controller
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
@@ -1187,7 +1187,7 @@ class ServicesController extends Controller
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
@@ -14,7 +14,7 @@ class ProfileAvatarController extends Controller
return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=300',
'Cache-Control' => 'private, max-age=31536000, immutable',
]);
}
}
@@ -15,6 +15,9 @@ class ProjectIconController extends Controller
abort_if($contents === null, 404);
return response($contents)->header('Content-Type', 'image/jpeg');
return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=31536000, immutable',
]);
}
}
+8 -2
View File
@@ -83,7 +83,10 @@ class Github extends Controller
}
}
if ($x_github_event === 'pull_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@@ -334,7 +337,10 @@ class Github extends Controller
}
}
if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $applications->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
+2 -6
View File
@@ -2495,12 +2495,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
destination: $destination,
no_questions_asked: true,
);
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [
'project_uuid' => data_get($this->application, 'environment.project.uuid'),
'application_uuid' => data_get($this->application, 'uuid'),
'deployment_uuid' => $deployment_uuid,
'environment_uuid' => data_get($this->application, 'environment.uuid'),
]));
$deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}";
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}");
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\Database\BackupMissing;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
ScheduledDatabaseBackup::query()
->with(['team', 'database', 'latest_log'])
->where('enabled', true)
->where('missing_backup_notification_days', '>', 0)
->chunkById(100, function ($backups): void {
foreach ($backups as $backup) {
$this->notifyIfMissing($backup);
}
});
}
private function notifyIfMissing(ScheduledDatabaseBackup $backup): void
{
$lastExecutionAt = $backup->last_execution_at ?? $backup->latest_log?->created_at;
$lastActivityAt = $lastExecutionAt ?? $backup->created_at;
if (! $lastActivityAt || $lastActivityAt->isAfter(now()->subDays($backup->missing_backup_notification_days))) {
return;
}
if ($backup->missing_backup_notification_sent_at?->greaterThanOrEqualTo($lastActivityAt)) {
return;
}
if (! $backup->team) {
Log::warning("Cannot send missing backup notification for backup {$backup->id}: team not found");
return;
}
if ($backup->team->getEnabledChannels('backup_failure') === []) {
return;
}
$backup->team->notify(new BackupMissing($backup, $lastExecutionAt));
$backup->forceFill(['missing_backup_notification_sent_at' => now()])->save();
}
}
+11 -1
View File
@@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
@@ -33,8 +35,13 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
$this->server->refresh();
$this->clearOutdatedInfo();
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) {
return;
}
// Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
@@ -116,7 +123,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
$this->server->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
}
/**
+14
View File
@@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
Server::query()
->where(function ($query) {
$query->whereNull('proxy')
->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value);
})
->where(function ($query) {
$query->whereNotNull('detected_traefik_version')
->orWhereNotNull('traefik_outdated_info');
})
->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
// Load versions from cached data
$traefikVersions = get_traefik_versions();
+6 -1
View File
@@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function __construct(public Server $server) {}
private static function helperContainersCommand(): string
{
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
}
public function handle(): void
{
try {
@@ -36,7 +41,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
+9 -16
View File
@@ -5,7 +5,6 @@ namespace App\Jobs;
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabase;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
@@ -483,7 +482,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
])
->with([
'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
'databases:id,service_id,status,last_online_at,is_public,name,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
'databases:id,service_id,status,last_online_at,is_public,name',
])
->get();
}
@@ -506,8 +505,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
'restart_count',
'last_restart_at',
'last_restart_type',
'max_restart_count',
'restart_limit_reached',
];
return collect([
@@ -675,7 +672,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
$restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0;
if ($subResource->trackRestartCount($restartCount)) {
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
@@ -821,11 +818,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$database->status = $containerStatus;
$database->save();
}
if (is_numeric($restartCount) && $database->trackRestartCount((int) $restartCount)) {
StopDatabase::dispatch($database, false, false, false);
$database->team()?->notify(new ApplicationRestartLimitReached($database));
return;
if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if (! $this->isCompleteSnapshot()) {
return;
@@ -883,16 +881,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databasesByUuid->get($databaseUuid);
if ($database) {
if ($database->stoppedAfterRestartLimit()) {
return;
}
if (! str($database->status)->startsWith('exited')) {
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
'restart_limit_reached' => false,
]);
}
if ($database->is_public) {
@@ -918,9 +912,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Batch update service databases
if ($notFoundServiceDatabaseIds->isNotEmpty()) {
ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds)
->where('restart_limit_reached', false)
->where('status', '!=', 'exited')
->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]);
->update(['status' => 'exited']);
}
}
+4 -1
View File
@@ -66,7 +66,10 @@ class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
);
$regenerated->push($certificate);
$resource = $certificate->database;
if ($resource) {
$regenerated->push($resource);
}
} catch (\Exception $e) {
Log::error('Failed to regenerate SSL certificate: '.$e->getMessage());
}
+13 -2
View File
@@ -74,7 +74,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
Subscription::updateOrCreate(
$subscription = Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
@@ -83,6 +83,12 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
'stripe_past_due' => false,
]
);
logger()->info('Stripe subscription checkout completed.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_checkout_session_id' => data_get($data, 'id'),
'stripe_subscription_id' => $subscription->stripe_subscription_id,
]);
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
@@ -218,7 +224,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
Subscription::updateOrCreate(
$subscription = Subscription::firstOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
@@ -226,6 +232,11 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
'stripe_invoice_paid' => false,
]
);
if (! $subscription->stripe_subscription_id && $subscription->stripe_customer_id === $customerId) {
$subscription->update(['stripe_subscription_id' => $subscriptionId]);
} elseif ($subscription->stripe_customer_id !== $customerId) {
throw new \RuntimeException("Stripe customer ID mismatch for team {$teamId}: stored {$subscription->stripe_customer_id}, event {$customerId}.");
}
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
+74 -22
View File
@@ -73,6 +73,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
$backupLocation = $backupDirectory.'/'.$filename;
$this->execution->update(['filename' => $backupLocation]);
$streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup;
try {
$source = $this->backup->sourcePath();
@@ -86,11 +87,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage);
$archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume .";
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
.' sh -c '.escapeshellarg($archiveScript)
.' > '.escapeshellarg($backupLocation);
if ($streamToS3) {
$this->execution->update(['local_storage_deleted' => true]);
$archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image);
$this->execution->update(['s3_cleanup_pending' => true]);
} else {
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
.' sh -c '.escapeshellarg($archiveScript)
.' > '.escapeshellarg($backupLocation);
}
if ($this->backup->stop_during_backup) {
$containers = $this->containersUsingVolume($source, $server);
@@ -104,21 +111,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
instant_remote_process([
$archiveOutput = instant_remote_process(array_filter([
$verifySourceCommand,
'mkdir -p '.escapeshellarg($backupDirectory),
$streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory),
$archiveCommand,
], $server, timeout: $this->timeout, disableMultiplexing: true);
]), $server, timeout: $this->timeout, disableMultiplexing: true);
$this->execution->update([
'stop_container_ids' => null,
'stop_recovery_pending' => false,
]);
$size = (int) instant_remote_process(
['du -b '.escapeshellarg($backupLocation).' | cut -f1'],
$server,
disableMultiplexing: true,
);
$size = $streamToS3
? (int) str($archiveOutput)->trim()->afterLast("\n")->toString()
: (int) instant_remote_process(
['du -b '.escapeshellarg($backupLocation).' | cut -f1'],
$server,
disableMultiplexing: true,
);
if ($size <= 0) {
throw new \RuntimeException('The storage backup archive is empty or was not created.');
@@ -127,9 +136,12 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$warning = null;
$s3Uploaded = null;
$s3CleanupPending = false;
$localStorageDeleted = false;
$localStorageDeleted = $streamToS3;
if ($this->backup->save_s3) {
if ($streamToS3) {
$s3Uploaded = true;
$this->execution->update(['s3_cleanup_pending' => false]);
} elseif ($this->backup->save_s3) {
$s3CleanupPending = true;
$this->execution->update(['s3_cleanup_pending' => true]);
@@ -181,13 +193,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
}
} catch (Throwable $exception) {
$recoveryError = $this->recoverIncompleteBackup($this->execution);
$archiveDeleted = false;
$archiveDeleted = $streamToS3;
try {
deleteBackupsLocally($backupLocation, $server, throwError: true);
$archiveDeleted = true;
} catch (Throwable $cleanupException) {
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage();
if ($streamToS3) {
$exception = new \RuntimeException(
'S3-only streaming backup failed: '.$exception->getMessage()
.'. The S3 destination may not support streaming uploads. Enable local backups to use the local archive upload method.',
previous: $exception,
);
}
if (! $streamToS3) {
try {
deleteBackupsLocally($backupLocation, $server, throwError: true);
$archiveDeleted = true;
} catch (Throwable $cleanupException) {
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage();
}
}
$s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending;
@@ -195,7 +217,9 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->execution->update([
'status' => 'failed',
'message' => $exception->getMessage().$recoveryError,
'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation,
'filename' => $streamToS3
? ($s3CleanupPending ? $backupLocation : null)
: ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation),
'local_storage_deleted' => $archiveDeleted,
]);
@@ -338,6 +362,34 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string
{
$s3 = $this->backup->s3;
if (! $s3) {
$this->backup->update(['save_s3' => false, 's3_storage_id' => null]);
throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.');
}
$s3->testConnection(shouldSave: true);
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts()))
->map(fn (string $option): string => '--resolve '.escapeshellarg($option))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$destination = 'temporary/'.$s3->bucket.$backupLocation;
$streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary '
.escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret)
.' >/dev/null && ('.$archiveScript.' | mc pipe --quiet'.$resolveOptions.' '.escapeshellarg($destination).' >/dev/null)'
.' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination)
.' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p');
return 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
.' sh -c '.escapeshellarg($streamScript);
}
private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void
{
if (! isDev()) {
@@ -209,19 +209,20 @@ trait InteractsWithCloudflareDomainConnect
}
}
// Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa).
try {
$settings = instanceSettings();
$publicV4 = data_get($settings, 'public_ipv4');
$publicV6 = data_get($settings, 'public_ipv6');
if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipv4 = $publicV4;
if ($this->usesInstanceNetworkAddressesForDnsHints()) {
try {
$settings = instanceSettings();
$publicV4 = data_get($settings, 'public_ipv4');
$publicV6 = data_get($settings, 'public_ipv6');
if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipv4 = $publicV4;
}
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
}
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
}
return [$ipv4, $ipv6];
@@ -253,5 +254,7 @@ trait InteractsWithCloudflareDomainConnect
return null;
}
abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool;
abstract protected function authorizeUpdateForDomainConnect(): void;
}
@@ -27,12 +27,6 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $isGitShallowCloneEnabled = false;
#[Validate(['boolean'])]
public bool $isPreviewDeploymentsEnabled = false;
#[Validate(['boolean'])]
public bool $isPrDeploymentsPublicEnabled = false;
#[Validate(['boolean'])]
public bool $isAutoDeployEnabled = true;
@@ -107,8 +101,6 @@ class Advanced extends Component
$this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled;
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
$this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled;
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->settings->is_gpu_enabled = $this->isGpuEnabled;
@@ -136,8 +128,6 @@ class Advanced extends Component
$this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled;
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
$this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false;
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
$this->gpuDriver = $this->application->settings->gpu_driver;
+49 -8
View File
@@ -150,7 +150,15 @@ class Domains extends Component
public function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->loadDomainState();
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& ($row['service'] ?? null) === ($editingRow['service'] ?? null));
$this->editingIndex = $index === false ? null : (int) $index;
}
}
public function pollDnsChecks(): void
@@ -227,7 +235,9 @@ class Domains extends Component
$this->isCompose = $this->application->build_pack === 'dockercompose';
$this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false;
$this->redirect = $this->application->redirect ?? 'both';
if ($this->pendingAction !== 'redirect' || $this->isCompose) {
$this->redirect = $this->application->redirect ?? 'both';
}
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
$settings = instanceSettings();
@@ -254,6 +264,9 @@ class Domains extends Component
}
$this->composeServices = [];
$pendingRedirect = $this->pendingRedirectService !== null
? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null)
: null;
$this->serviceRedirects = [];
if ($this->isCompose) {
try {
@@ -290,7 +303,9 @@ class Domains extends Component
$serviceEntry = $domains[$serviceName] ?? null;
$storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null;
$this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect(
is_string($storedRedirect) ? $storedRedirect : null
$this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService
? $pendingRedirect
: (is_string($storedRedirect) ? $storedRedirect : null)
);
}
}
@@ -500,7 +515,7 @@ class Domains extends Component
{
$key = $this->domainDnsStatusKey($url, $service);
$entry = $stored[$key] ?? null;
$port = $this->effectiveDomainInternalPort($url);
$port = $this->effectiveDomainInternalPort($url, $service);
$row = [
'url' => $url,
@@ -532,7 +547,7 @@ class Domains extends Component
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
protected function effectiveDomainInternalPort(string $url): array
protected function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->application->domain_port_overrides ?? [];
@@ -554,6 +569,21 @@ class Domains extends Component
];
}
$composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
if ($this->isCompose && $service !== null) {
return [
'internal_port' => null,
'has_port_override' => false,
];
}
if ($this->application->settings?->is_static) {
return [
'internal_port' => 80,
@@ -598,7 +628,7 @@ class Domains extends Component
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
}
protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
{
if ($this->forceUseUnknownPort || $port === null) {
return false;
@@ -607,7 +637,7 @@ class Domains extends Component
return false;
}
return $this->application->portRequiresConfirmation($port);
return $this->application->portRequiresConfirmation($port, $serviceName);
}
protected function openPortWarning(?int $port, string $action): void
@@ -653,6 +683,11 @@ class Domains extends Component
$this->authorize('update', $this->application);
}
protected function usesInstanceNetworkAddressesForDnsHints(): bool
{
return $this->application->destination?->server?->id === 0;
}
public function checkAllDns(): void
{
$this->authorize('update', $this->application);
@@ -953,7 +988,13 @@ class Domains extends Component
return;
}
$this->authorize('update', $this->application);
$wasRedirect = $this->pendingAction === 'redirect';
$this->pendingAction = null;
$this->pendingRedirectService = null;
if ($wasRedirect) {
$this->refreshDomains();
}
}
public function addDomain(): void
@@ -998,7 +1039,7 @@ class Domains extends Component
}
}
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
return;
@@ -1395,7 +1436,7 @@ class Domains extends Component
return;
}
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) {
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
return;
@@ -38,6 +38,7 @@ class PreviewDomains extends Component
public function mount(): void
{
$this->authorize('view', $this->preview->application);
$this->refreshDomains();
if ($this->preview->application->build_pack === 'dockercompose') {
$this->newDomainService = $this->composeServices()[0] ?? null;
@@ -74,7 +75,7 @@ class PreviewDomains extends Component
return;
}
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
return;
@@ -94,7 +95,7 @@ class PreviewDomains extends Component
? ($this->composeServices()[0] ?? null)
: null;
$this->forceUseUnknownPort = false;
$this->dispatch('close-modal');
$this->dispatch('close-preview-domain-add', previewId: $this->preview->id);
try {
$server = $this->preview->application->destination?->server;
@@ -149,6 +150,7 @@ class PreviewDomains extends Component
public function startEdit(int $index): void
{
$this->authorize('update', $this->preview->application);
if (! isset($this->domainRows[$index])) {
return;
}
@@ -159,7 +161,8 @@ class PreviewDomains extends Component
if (filled($savedPort)) {
$this->editingDomainParts['port'] = (string) $savedPort;
}
$this->dispatch('open-preview-domain-edit');
$this->resetErrorBag('editingDomainParts.host');
$this->dispatch('open-preview-domain-edit', previewId: $this->preview->id);
}
public function updateDomain(): void
@@ -173,7 +176,7 @@ class PreviewDomains extends Component
return;
}
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
return;
@@ -193,7 +196,7 @@ class PreviewDomains extends Component
return;
}
$this->forceUseUnknownPort = false;
$this->dispatch('close-preview-domain-edit');
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
$this->dispatch('success', 'Domain updated.');
$this->checkDomainDns($index);
}
@@ -229,6 +232,12 @@ class PreviewDomains extends Component
if (! isset($this->domainRows[$index])) {
return;
}
if ($this->editingIndex === $index) {
$this->editingIndex = null;
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
} elseif ($this->editingIndex !== null && $this->editingIndex > $index) {
$this->editingIndex--;
}
unset($this->domainRows[$index]);
$this->domainRows = array_values($this->domainRows);
if (! $this->persistDomains()) {
@@ -268,6 +277,7 @@ class PreviewDomains extends Component
public function pollDnsChecks(): void
{
$this->authorize('view', $this->preview->application);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
@@ -321,6 +331,7 @@ class PreviewDomains extends Component
private function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->preview->refresh();
$statuses = $this->preview->domain_dns_statuses ?? [];
$rows = [];
@@ -336,6 +347,11 @@ class PreviewDomains extends Component
}
}
$this->domainRows = $rows;
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& $row['service'] === $editingRow['service']);
$this->editingIndex = $index === false ? null : (int) $index;
}
}
private function persistDomains(): bool
@@ -349,13 +365,16 @@ class PreviewDomains extends Component
return false;
}
$domains = collect($composeServices)
->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']])
->all();
$existingDomains = json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [];
$domains = [];
foreach ($composeServices as $service) {
$domains[$service] = is_array($existingDomains[$service] ?? null) ? $existingDomains[$service] : [];
$domains[$service]['domain'] = '';
}
$validRows = collect($this->domainRows)
->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true));
foreach ($validRows->groupBy('service') as $service => $rows) {
$domains[$service] = ['domain' => $rows->pluck('url')->implode(',')];
$domains[$service]['domain'] = $rows->pluck('url')->implode(',');
}
$this->preview->docker_compose_domains = json_encode($domains);
$this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null;
@@ -439,11 +458,23 @@ class PreviewDomains extends Component
private function makeRow(string $url, ?string $service, array $statuses = []): array
{
$status = $statuses[$this->statusKey($url, $service)] ?? [];
$port = $this->effectiveDomainInternalPort($url);
$port = $this->effectiveDomainInternalPort($url, $service);
$redirect = 'both';
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) {
$usesPreviewRedirect = (int) $this->preview->application->compose_parsing_version >= 3;
$domains = json_decode(($usesPreviewRedirect
? $this->preview->docker_compose_domains
: $this->preview->application->docker_compose_domains) ?: '[]', true) ?: [];
$storedRedirect = $usesPreviewRedirect
? ($domains[$service]['redirect'] ?? null)
: data_get($domains, "$service.redirect");
$redirect = in_array($storedRedirect, ['www', 'non-www', 'both'], true) ? $storedRedirect : 'both';
}
return [
'url' => $url,
'service' => $service,
'redirect' => $redirect,
'internal_port' => $port['internal_port'],
'has_port_override' => $port['has_port_override'],
'dns_status' => $status['status'] ?? 'pending',
@@ -478,7 +509,7 @@ class PreviewDomains extends Component
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
}
private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
{
if ($this->forceUseUnknownPort || $port === null) {
return false;
@@ -487,7 +518,7 @@ class PreviewDomains extends Component
return false;
}
return $this->preview->application->portRequiresConfirmation($port);
return $this->preview->application->portRequiresConfirmation($port, $serviceName);
}
private function openPortWarning(?int $port, string $action): void
@@ -500,7 +531,7 @@ class PreviewDomains extends Component
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
private function effectiveDomainInternalPort(string $url): array
private function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->preview->domain_port_overrides ?? [];
@@ -522,6 +553,21 @@ class PreviewDomains extends Component
];
}
$composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) {
return [
'internal_port' => null,
'has_port_override' => false,
];
}
if ($this->preview->application->settings?->is_static) {
return [
'internal_port' => 80,
@@ -19,6 +19,10 @@ class Previews extends Component
public Application $application;
public bool $isPreviewDeploymentsEnabled = false;
public bool $isPrDeploymentsPublicEnabled = false;
public string $deployment_uuid;
public array $parameters;
@@ -41,11 +45,29 @@ class Previews extends Component
public function mount()
{
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->pull_requests = collect();
$this->parameters = get_route_parameters();
$this->syncDockerTags();
}
public function savePreviewSettings(): void
{
$this->authorize('update', $this->application);
$this->validate([
'isPreviewDeploymentsEnabled' => 'boolean',
'isPrDeploymentsPublicEnabled' => 'boolean',
]);
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
$this->application->settings->save();
$this->dispatch('success', 'Settings saved.');
$this->dispatch('configurationChanged');
}
private function syncDockerTags(): void
{
$this->previewDockerTags = [];
@@ -85,6 +85,9 @@ class BackupEdit extends Component
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int|string $timeout = 3600;
#[Validate(['required', 'integer', 'min:0', 'max:365'])]
public int $missingBackupNotificationDays = 0;
public function getListeners(): array
{
// Keep "Backup Now" in sync when the database starts/stops without a full page refresh.
@@ -152,6 +155,7 @@ class BackupEdit extends Component
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
$this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays;
$this->customValidate();
$this->backup->save();
} else {
@@ -170,6 +174,7 @@ class BackupEdit extends Component
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
$this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days;
}
}
@@ -245,6 +250,14 @@ class BackupEdit extends Component
try {
$this->authorize('manageBackups', $this->backup->database);
$database = $this->backup->database->refresh();
$this->status = $database->status;
if ($database->id !== 0 && ! str($database->status)->startsWith('running')) {
$this->dispatch('error', 'The database must be running to start a backup.');
return;
}
DatabaseBackupJob::dispatch($this->backup);
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
@@ -17,6 +17,13 @@ class BackupNow extends Component
try {
$this->authorize('manageBackups', $this->backup->database);
$database = $this->backup->database->refresh();
if ($database->id !== 0 && ! str($database->status)->startsWith('running')) {
$this->dispatch('error', 'The database must be running to start a backup.');
return;
}
DatabaseBackupJob::dispatch($this->backup);
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
@@ -85,11 +85,10 @@ class CreateScheduledBackup extends Component
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($database->getMorphClass() === ServiceDatabase::class) {
$service = $database->service;
$this->redirectRoute('project.service.database.backup.show', [
$this->redirectRoute('project.service.volume-backups.index', [
'project_uuid' => $service->project()->uuid,
'environment_uuid' => $service->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
'backup_uuid' => $databaseBackup->uuid,
], navigate: true);
} else {
@@ -9,16 +9,21 @@ use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Query\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\WithPagination;
class BackupExecutions extends Component
{
use AuthorizesRequests;
use WithPagination;
public Service $service;
public int $perPage = 10;
public bool $executionModalOpen = false;
public ?array $selectedExecution = null;
@@ -40,10 +45,18 @@ class BackupExecutions extends Component
$this->authorize('view', $this->service);
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage('executionsPage');
}
public function openExecution(string $executionUuid): void
{
$this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid);
abort_unless($this->selectedExecution, 404);
$this->authorize('view', $this->service);
$execution = $this->executionQuery($executionUuid)->first();
abort_unless($execution, 404);
$this->selectedExecution = $this->formatExecutions(collect([$execution]))->first();
$this->executionModalOpen = true;
}
@@ -55,70 +68,89 @@ class BackupExecutions extends Component
public function render(): View
{
$this->authorize('view', $this->service);
$executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage');
if ($executions->currentPage() > $executions->lastPage()) {
$this->setPage($executions->lastPage(), 'executionsPage');
$executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage');
}
$executions->setCollection($this->formatExecutions($executions->getCollection()));
return view('livewire.project.service.backup-executions', [
'executions' => $this->executions(),
'executions' => $executions,
]);
}
private function executions(): Collection
private function executionQuery(?string $uuid = null): Builder
{
$databaseScheduleIds = ScheduledDatabaseBackup::query()
->where('database_type', (new ServiceDatabase)->getMorphClass())
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
->pluck('id');
->select('id');
$volumeScheduleIds = ScheduledVolumeBackup::query()
->forService($this->service)
->select('id');
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
->with('scheduledDatabaseBackup.database')
->select('id', 'uuid', 'created_at')
->selectRaw("'database' as type")
->whereIn('scheduled_database_backup_id', $databaseScheduleIds)
->latest()
->limit(100)
->get()
->map(fn (ScheduledDatabaseBackupExecution $execution): array => [
'id' => 'database:'.$execution->id,
->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid));
$volumeExecutions = ScheduledVolumeBackupExecution::query()
->select('id', 'uuid', 'created_at')
->selectRaw("'storage' as type")
->whereIn('scheduled_volume_backup_id', $volumeScheduleIds)
->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid));
return $databaseExecutions->toBase()
->unionAll($volumeExecutions->toBase())
->orderByDesc('created_at')
->orderByDesc('id')
->orderBy('type');
}
private function formatExecutions(Collection $rows): Collection
{
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3'])
->whereIn('id', $rows->where('type', 'database')->pluck('id'))
->get()->keyBy('id');
$volumeExecutions = ScheduledVolumeBackupExecution::query()
->with(['scheduledVolumeBackup.backupable.resource', 's3'])
->whereIn('id', $rows->where('type', 'storage')->pluck('id'))
->get()->keyBy('id');
return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array {
$isDatabase = $row->type === 'database';
$execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id);
$schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup;
$storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3;
if ($storage?->team_id !== currentTeam()->id) {
$storage = null;
}
$storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable';
if ($isDatabase && ! $schedule->save_s3) {
$storageLabel = 'Not configured';
} elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
$storageLabel = 'No destination recorded';
}
return [
'id' => $row->type.':'.$execution->id,
'uuid' => $execution->uuid,
'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name,
'type' => 'Database',
'schedule' => $execution->scheduledDatabaseBackup->frequency,
'target' => $isDatabase ? ($schedule->database->human_name ?: $schedule->database->name) : $schedule->targetName(),
'type' => $isDatabase ? 'Database' : $schedule->targetType(),
'schedule' => $schedule->frequency,
's3_tooltip' => ($isDatabase ? 'Current schedule S3 storage: ' : 'S3 storage: ').$storageLabel,
'status' => $execution->status,
'started_at' => $execution->created_at,
'size' => $execution->size,
'message' => $execution->message,
'filename' => $execution->filename,
'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted
? route('download.backup', $execution->id)
? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id)
: null,
]);
$volumeSchedules = ScheduledVolumeBackup::query()
->with('backupable.resource')
->forService($this->service)
->get()
->keyBy('id');
$volumeExecutions = ScheduledVolumeBackupExecution::query()
->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys())
->latest()
->limit(100)
->get()
->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array {
$schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id);
return [
'id' => 'storage:'.$execution->id,
'uuid' => $execution->uuid,
'target' => $schedule->targetName(),
'type' => $schedule->targetType(),
'schedule' => $schedule->frequency,
'status' => $execution->status,
'started_at' => $execution->created_at,
'size' => $execution->size,
'message' => $execution->message,
'filename' => $execution->filename,
'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted
? route('download.volume-backup', $execution->id)
: null,
];
});
return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values();
];
});
}
}
+43 -4
View File
@@ -129,9 +129,17 @@ class Domains extends Component
public function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->service->refresh();
$this->service->load(['applications', 'server']);
$this->loadDomainState();
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& (int) $row['service_application_id'] === (int) $editingRow['service_application_id']);
$this->editingIndex = $index === false ? null : (int) $index;
}
}
public function pollDnsChecks(): void
@@ -239,9 +247,14 @@ class Domains extends Component
])
->all();
$pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null;
$this->serviceRedirects = [];
foreach ($this->service->applications as $app) {
$this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null);
$this->serviceRedirects[$app->id] = $this->normalizeRedirect(
$this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId
? $pendingRedirect
: $app->redirect
);
}
if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) {
@@ -459,6 +472,11 @@ class Domains extends Component
$this->authorize('update', $this->service);
}
protected function usesInstanceNetworkAddressesForDnsHints(): bool
{
return $this->service->server?->id === 0;
}
public function checkAllDns(): void
{
$this->authorize('update', $this->service);
@@ -919,6 +937,7 @@ class Domains extends Component
}
$toAdd = collect();
$portOverrides = $app->domain_port_overrides ?? [];
foreach ($current as $url) {
$counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true);
if ($counterpart === null) {
@@ -935,6 +954,11 @@ class Domains extends Component
continue;
}
$port = $this->effectiveDomainInternalPort($url, $app);
if ($port['has_port_override']) {
$portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port'];
}
$knownHosts[$hostKey] = true;
$toAdd->push($counterpart);
}
@@ -943,12 +967,13 @@ class Domains extends Component
return true;
}
$app->domain_port_overrides = $portOverrides ?: null;
$merged = $current->merge($toAdd)->unique()->values();
$this->pendingAction = 'redirect';
$this->pendingRedirectServiceApplicationId = $app->id;
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
if (! $this->saveDomainListForApp($app, $merged)) {
// Counterparts inherit an existing port, so only domain conflicts need confirmation.
if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) {
return false;
}
@@ -975,11 +1000,24 @@ class Domains extends Component
return;
}
if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) {
$this->setServiceRedirect($this->pendingRedirectServiceApplicationId);
return;
}
$this->addDomain();
}
public function cancelRemovePort(): void
{
$this->authorize('update', $this->service);
if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) {
$app = $this->findServiceApp($this->pendingRedirectServiceApplicationId);
$this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect);
}
$this->pendingRedirectServiceApplicationId = null;
$this->showPortWarningModal = false;
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
@@ -1416,6 +1454,7 @@ class Domains extends Component
ServiceApplication $app,
Collection $domains,
bool $checkConflicts = true,
bool $checkPorts = true,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@@ -1442,7 +1481,7 @@ class Domains extends Component
}
}
if (! $this->forceRemovePort) {
if ($checkPorts && ! $this->forceRemovePort) {
$requiredPort = $app->getRequiredPort();
if ($requiredPort !== null && $domainString) {
$previousFqdn = $app->getOriginal('fqdn');
+2 -2
View File
@@ -308,10 +308,10 @@ class FileStorage extends Component
{
return view('livewire.project.service.file-storage', [
'directoryDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'],
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'],
],
'fileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'],
],
'hostFileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],
+24 -12
View File
@@ -307,36 +307,48 @@ class Index extends Component
public function instantSave()
{
$this->authorize('update', $this->serviceDatabase);
try {
$this->authorize('update', $this->serviceDatabase);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncDatabaseData(true);
if ($this->serviceDatabase->is_public) {
if (! str($this->serviceDatabase->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
if ($this->isPublic) {
if (! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
$this->serviceDatabase->is_public = false;
return;
}
if (! str($this->serviceDatabase->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
$this->persistPublicAccess();
StartDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
$this->persistPublicAccess();
StopDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->persistPublicAccess();
return handleError($e, $this);
}
}
private function persistPublicAccess(): void
{
$this->serviceDatabase->update([
'is_public' => $this->isPublic,
'public_port' => $this->publicPort ?: null,
'public_port_timeout' => $this->publicPortTimeout ?: null,
]);
}
public function submitDatabase()
{
try {
@@ -11,6 +11,7 @@ use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Url;
use Livewire\Component;
class Index extends Component
@@ -23,6 +24,9 @@ class Index extends Component
public string $search = '';
#[Url(as: 'backup_uuid', except: '')]
public string $backupUuid = '';
public bool $scheduleModalOpen = false;
public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null;
@@ -38,6 +42,7 @@ class Index extends Component
return [
'refreshVolumeBackups' => '$refresh',
'modalClosed' => 'closeScheduleModal',
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
"echo-private:team.{$teamId},BackupCreated" => '$refresh',
];
}
@@ -49,10 +54,14 @@ class Index extends Component
$this->parameters = get_route_parameters();
$this->search = request()->string('search')->toString();
if ($this->backupUuid !== '') {
$this->openSchedule($this->backupUuid);
}
}
public function openSchedule(string $backupUuid): void
{
$this->authorize('update', $this->service);
$this->loadSelectedSchedule($backupUuid);
$this->s3s = currentTeam()->s3s;
$this->scheduleModalOpen = true;
@@ -60,6 +69,7 @@ class Index extends Component
public function closeScheduleModal(): void
{
$this->backupUuid = '';
$this->scheduleModalOpen = false;
$this->selectedDatabaseBackup = null;
$this->selectedVolumeBackup = null;
@@ -72,6 +82,12 @@ class Index extends Component
$this->loadSelectedSchedule($backupUuid);
abort_unless($this->selectedDatabaseBackup, 404);
$this->authorize('manageBackups', $this->selectedDatabaseBackup->database);
if (! str($this->selectedDatabaseBackup->database->status)->startsWith('running')) {
$this->selectedDatabaseBackup = null;
$this->dispatch('error', 'The database must be running to start a backup.');
return;
}
DatabaseBackupJob::dispatch($this->selectedDatabaseBackup);
} else {
abort_unless($type === 'storage', 404);
@@ -818,19 +818,21 @@ class All extends Component
{
$isMember = auth()->user()?->isMember();
return $variables->map(function ($item) use ($isMember) {
if ($isMember) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return $variables
->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key))
->map(function ($item) use ($isMember) {
if ($isMember) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
return "$item->key=$item->value";
})->join("\n");
}
public function switch()
@@ -908,8 +910,7 @@ class All extends Component
$deletedCount = $this->deleteRemovedVariables(false, $variables);
if ($deletedCount > 0) {
$changesMade = true;
} elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) {
// If we tried to delete but couldn't (due to Docker Compose), mark as error
} elseif ($deletedCount < 0) {
$errorOccurred = true;
}
@@ -926,8 +927,7 @@ class All extends Component
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
if ($deletedPreviewCount > 0) {
$changesMade = true;
} elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) {
// If we tried to delete but couldn't (due to Docker Compose), mark as error
} elseif ($deletedPreviewCount < 0) {
$errorOccurred = true;
}
@@ -988,6 +988,12 @@ class All extends Component
// Get all environment variables that will be deleted
$variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get();
// Generated Compose variables are managed by Coolify and must survive a bulk
// replacement even when they are omitted from the pasted environment file.
$variablesToDelete = $variablesToDelete->reject(
fn (EnvironmentVariable $environmentVariable): bool => $this->isProtectedEnvironmentVariable($environmentVariable->key)
);
// If there are no variables to delete, return 0
if ($variablesToDelete->isEmpty()) {
return 0;
@@ -1001,13 +1007,13 @@ class All extends Component
if ($isUsed) {
$this->dispatch('error', "Cannot delete environment variable '{$envVar->key}' <br><br>Please remove it from the Docker Compose file first.");
return 0;
return -1;
}
}
}
// If we get here, no variables are used in Docker Compose, so we can delete them
$this->resource->$method()->whereNotIn('key', array_keys($variables))->delete();
$this->resource->$method()->whereKey($variablesToDelete->modelKeys())->delete();
return $variablesToDelete->count();
}
@@ -102,7 +102,7 @@ class Add extends Component
}
}
private function saveScheduledTask(): mixed
private function saveScheduledTask(): void
{
try {
$task = new ScheduledTask;
@@ -128,7 +128,7 @@ class Add extends Component
$this->dispatch('refreshTasks');
$this->dispatch('success', 'Scheduled task added.');
} catch (\Throwable $e) {
return handleError($e, $this);
handleError($e, $this);
}
}
@@ -147,7 +147,11 @@ class VolumeBackups extends Component
}
$this->resetErrorBag('s3StorageId');
$this->backup?->update(['s3_storage_id' => $this->s3StorageId]);
if (! $this->validateSettings()) {
return;
}
$this->backup = $this->persistBackup($this->enabled);
$this->dispatch('success', 'S3 storage updated.');
}
@@ -163,11 +167,11 @@ class VolumeBackups extends Component
$this->saveToS3 = ! $this->saveToS3;
$this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup;
$this->backup?->update([
'save_s3' => $this->saveToS3,
'disable_local_backup' => $this->disableLocalBackup,
's3_storage_id' => $this->s3StorageId,
]);
if (! $this->validateSettings()) {
return;
}
$this->backup = $this->persistBackup($this->enabled);
$this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.');
}
+2
View File
@@ -106,6 +106,8 @@ class Proxy extends Component
try {
$this->authorize('update', $this->server);
$this->server->proxy = null;
$this->server->detected_traefik_version = null;
$this->server->traefik_outdated_info = null;
$this->server->save();
$this->dispatch('reloadWindow');
+50 -1
View File
@@ -5,11 +5,29 @@ namespace App\Livewire\Server;
use App\Models\Server;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Component;
use Livewire\WithPagination;
class Resources extends Component
{
use AuthorizesRequests;
use WithPagination;
public int $perPage = 10;
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public ?Server $server = null;
@@ -93,6 +111,10 @@ class Resources extends Component
public function loadManagedContainers()
{
try {
if ($this->activeTab !== 'managed') {
$this->search = '';
$this->resetPage();
}
$this->activeTab = 'managed';
$this->server->refresh();
} catch (\Throwable $e) {
@@ -102,6 +124,10 @@ class Resources extends Component
public function loadUnmanagedContainers()
{
if ($this->activeTab !== 'unmanaged') {
$this->search = '';
$this->resetPage();
}
$this->activeTab = 'unmanaged';
try {
$this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray();
@@ -125,6 +151,29 @@ class Resources extends Component
public function render()
{
return view('livewire.server.resources');
$resources = $this->activeTab === 'managed'
? $this->server->definedResources()->sortBy('name', SORT_NATURAL)
: collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL);
$search = trim($this->search);
if ($search !== '') {
$nameKey = $this->activeTab === 'managed' ? 'name' : 'Names';
$resources = $resources->filter(fn ($resource) => str((string) data_get($resource, $nameKey))
->contains($search, ignoreCase: true));
}
$this->perPage = max(1, min(100, $this->perPage));
$lastPage = max(1, (int) ceil($resources->count() / $this->perPage));
$page = max(1, min((int) $this->getPage(), $lastPage));
if ($page !== $this->getPage()) {
$this->setPage($page);
}
return view('livewire.server.resources', [
'resources' => new LengthAwarePaginator(
$resources->forPage($page, $this->perPage)->values(),
$resources->count(),
$this->perPage,
$page,
),
]);
}
}
+30
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Server\Sentinel;
use App\Actions\Server\StartSentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
@@ -29,6 +30,35 @@ class Logs extends Component
$this->authorize('viewSentinel', $this->server);
}
public function enableSentinel(): void
{
$this->authorize('manageSentinel', $this->server);
try {
$this->server->refresh();
if ($this->server->isBuildServer()) {
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
if ($this->server->isSwarm()) {
$this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.');
return;
}
if ($this->server->isSentinelEnabled()) {
return;
}
StartSentinel::run($this->server, true);
$this->server->refresh();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Sentinel has been enabled.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.server.sentinel.logs');
+5
View File
@@ -56,6 +56,8 @@ class Advanced extends Component
public string $avatar_storage = 'local';
public ?string $image_cdn_url = null;
public array $avatar_storage_options = [];
public function rules()
@@ -75,6 +77,7 @@ class Advanced extends Component
'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean',
'domain_connect_private_key' => 'nullable|string',
'image_cdn_url' => 'nullable|url|max:255',
];
}
@@ -102,6 +105,7 @@ class Advanced extends Component
$this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id
? 's3:'.$this->settings->avatar_s3_storage_id
: 'local';
$this->image_cdn_url = $this->settings->image_cdn_url;
$this->avatar_storage_options = [
['value' => 'local', 'label' => 'Local storage'],
...S3Storage::query()
@@ -216,6 +220,7 @@ class Advanced extends Component
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
$this->settings->webhook_allow_localhost = $this->webhook_allow_localhost;
$this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null;
$this->saveAvatarStorageSetting();
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
+35 -3
View File
@@ -2,8 +2,11 @@
namespace App\Livewire\Subscription;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Jobs\ServerLimitCheckJob;
use App\Models\InstanceSettings;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
use Stripe\StripeClient;
@@ -49,12 +52,19 @@ class Index extends Component
return redirect($session->url);
}
public function getStripeStatus()
public function getStripeStatus(): mixed
{
$team = currentTeam();
$user = auth()->user();
abort_unless($team && $user?->isAdminOfTeam($team->id), 403);
try {
$subscription = currentTeam()->subscription;
$subscription = $team->subscription()->first();
if (! $subscription?->stripe_customer_id) {
return null;
}
$stripe = app(StripeClient::class);
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
$customer = $stripe->customers->retrieve($subscription->stripe_customer_id);
if ($customer) {
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);
$currentTeam = currentTeam()->id ?? null;
@@ -65,6 +75,26 @@ class Index extends Component
$subscription->update([
'stripe_subscription_id' => $foundSubscription->id,
]);
if ($status === 'active') {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
'stripe_plan_id' => data_get($foundSubscription, 'items.data.0.price.id'),
'stripe_cancel_at_period_end' => data_get($foundSubscription, 'cancel_at_period_end', false),
]);
if (str(data_get($foundSubscription, 'items.data.0.price.lookup_key'))->contains('dynamic')) {
$quantity = max(
UpdateSubscriptionQuantity::MIN_SERVER_LIMIT,
min((int) data_get($foundSubscription, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT)
);
$team->update(['custom_server_limit' => $quantity]);
ServerLimitCheckJob::dispatch($team);
}
$team->unsetRelation('subscription');
Cache::forget('user:'.$user->id.':team:'.$team->id);
return redirect()->route('subscription.show');
}
if ($status === 'unpaid') {
$this->isUnpaid = true;
}
@@ -82,6 +112,8 @@ class Index extends Component
} finally {
$this->loading = false;
}
return null;
}
public function render()
+34 -47
View File
@@ -2,22 +2,28 @@
namespace App\Livewire\Subscription;
use Illuminate\Support\Facades\Auth;
use App\Actions\Stripe\CreateCheckoutSession;
use App\Exceptions\CheckoutUnavailableException;
use Livewire\Component;
use Stripe\Checkout\Session;
use Stripe\Stripe;
use RuntimeException;
use Stripe\Exception\ApiErrorException;
class PricingPlans extends Component
{
public function subscribeStripe($type)
public function subscribeStripe(string $type): mixed
{
if (currentTeam()->subscription?->stripe_invoice_paid) {
$this->dispatch('error', 'Team already has an active subscription.');
$team = currentTeam();
$user = auth()->user();
return;
if (! $team || ! $user?->isAdminOfTeam($team->id)) {
abort(403);
}
Stripe::setApiKey(config('subscription.stripe_api_key'));
if ($team->subscription?->stripe_invoice_paid) {
$this->dispatch('error', 'Team already has an active subscription.');
return null;
}
$priceId = match ($type) {
'dynamic-monthly' => config('subscription.stripe_price_id_dynamic_monthly'),
@@ -28,48 +34,29 @@ class PricingPlans extends Component
if (! $priceId) {
$this->dispatch('error', 'Price ID not found! Please contact the administrator.');
return;
return null;
}
$payload = [
'allow_promotion_codes' => true,
'billing_address_collection' => 'required',
'client_reference_id' => Auth::id().':'.currentTeam()->id,
'line_items' => [[
'price' => $priceId,
'adjustable_quantity' => [
'enabled' => true,
'minimum' => 2,
],
'quantity' => 2,
]],
'tax_id_collection' => [
'enabled' => true,
],
'automatic_tax' => [
'enabled' => true,
],
'subscription_data' => [
'metadata' => [
'user_id' => Auth::id(),
'team_id' => currentTeam()->id,
],
],
'payment_method_collection' => 'if_required',
'mode' => 'subscription',
'success_url' => route('dashboard', ['success' => true]),
'cancel_url' => route('subscription.index', ['cancelled' => true]),
];
try {
$session = app(CreateCheckoutSession::class)->execute($team, $user, $priceId);
} catch (ApiErrorException $exception) {
report($exception);
$this->dispatch('error', 'Unable to confirm checkout with Stripe. Please try again shortly.');
$customer = currentTeam()->subscription?->stripe_customer_id ?? null;
if ($customer) {
$payload['customer'] = $customer;
$payload['customer_update'] = [
'name' => 'auto',
];
} else {
$payload['customer_email'] = Auth::user()->email;
return null;
} catch (CheckoutUnavailableException $exception) {
$message = $exception->getMessage();
if ($exception->billingPortalUrl) {
$message .= ' <a href="'.e($exception->billingPortalUrl).'" target="_blank" rel="noopener noreferrer" class="underline">Open billing portal</a>';
}
$this->dispatch('error', $message);
return null;
} catch (RuntimeException $exception) {
report($exception);
$this->dispatch('error', 'Unable to start checkout. Please try again shortly.');
return null;
}
$session = Session::create($payload);
return redirect($session->url, 303);
}
+19 -34
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\BuildPackTypes;
use App\Services\ConfigurationGenerator;
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
use App\Services\DeploymentConfiguration\ConfigurationDiff;
@@ -292,9 +293,11 @@ class Application extends BaseModel
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'];
if ($application->build_pack !== BuildPackTypes::DOCKERCOMPOSE->value || filled($application->fqdn)) {
$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();
}
@@ -623,32 +626,6 @@ class Application extends BaseModel
&& $this->restart_limit_reached === true;
}
public function taskLink($task_uuid)
{
if (data_get($this, 'environment.project.uuid')) {
$route = route('project.application.scheduled-tasks', [
'project_uuid' => data_get($this, 'environment.project.uuid'),
'environment_uuid' => data_get($this, 'environment.uuid'),
'application_uuid' => data_get($this, 'uuid'),
'task_uuid' => $task_uuid,
]);
$settings = instanceSettings();
if (data_get($settings, 'fqdn')) {
$url = Url::fromString($route);
$url = $url->withPort(null);
$fqdn = data_get($settings, 'fqdn');
$fqdn = str_replace(['http://', 'https://'], '', $fqdn);
$url = $url->withHost($fqdn);
return $url->__toString();
}
return $route;
}
return null;
}
public function settings()
{
return $this->hasOne(ApplicationSetting::class);
@@ -742,7 +719,7 @@ class Application extends BaseModel
);
}
public function gitCommitLink($link): string
public function gitCommitLink($link): ?string
{
if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) {
if (str($this->source->html_url)->contains('bitbucket')) {
@@ -759,6 +736,10 @@ class Application extends BaseModel
$git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH);
}
if (! filter_var($git_repository, FILTER_VALIDATE_URL)) {
return null;
}
$url = Url::fromString(Str::replaceEnd('.git', '', $git_repository));
$url = $url->withUserInfo('');
$commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit';
@@ -985,12 +966,16 @@ class Application extends BaseModel
}
/**
* Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains.
* Ports declared by the selected Compose service, or exposed and previously used application ports.
*
* @return list<int>
*/
public function availableInternalPorts(): array
public function availableInternalPorts(?string $serviceName = null): array
{
if ($this->build_pack === 'dockercompose') {
return dockerComposeServicePorts($this->docker_compose_raw, $serviceName);
}
$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);
@@ -1015,13 +1000,13 @@ class Application extends BaseModel
return $ports->unique()->sort()->values()->all();
}
public function portRequiresConfirmation(?int $port): bool
public function portRequiresConfirmation(?int $port, ?string $serviceName = null): bool
{
if ($port === null || $port <= 0) {
return false;
}
return ! in_array($port, $this->availableInternalPorts(), true);
return ! in_array($port, $this->availableInternalPorts($serviceName), true);
}
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
+1
View File
@@ -57,6 +57,7 @@ class InstanceSettings extends Model
'webhook_allow_localhost',
'avatar_storage_type',
'avatar_s3_storage_id',
'image_cdn_url',
'is_dashboard_force_https_enabled',
];
+4
View File
@@ -14,6 +14,9 @@ class ScheduledDatabaseBackup extends BaseModel
'dump_all' => 'boolean',
'database_backup_retention_max_storage_locally' => 'float',
'database_backup_retention_max_storage_s3' => 'float',
'missing_backup_notification_days' => 'integer',
'missing_backup_notification_sent_at' => 'datetime',
'last_execution_at' => 'datetime',
];
}
@@ -37,6 +40,7 @@ class ScheduledDatabaseBackup extends BaseModel
'database_backup_retention_max_storage_s3',
'timeout',
'disable_local_backup',
'missing_backup_notification_days',
];
public static function ownedByCurrentTeam()
@@ -6,6 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ScheduledDatabaseBackupExecution extends BaseModel
{
protected static function booted(): void
{
static::created(function (ScheduledDatabaseBackupExecution $execution): void {
$execution->scheduledDatabaseBackup()->update(['last_execution_at' => $execution->created_at ?? now()]);
});
}
protected $fillable = [
'uuid',
'scheduled_database_backup_id',
+1 -1
View File
@@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel
'started_at' => 'datetime',
'finished_at' => 'datetime',
'retry_count' => 'integer',
'duration' => 'decimal:2',
'duration' => 'float',
];
}
+2
View File
@@ -1813,6 +1813,8 @@ $siteAddress {
$this->proxy->set('last_saved_proxy_configuration', null);
$this->proxy->set('last_saved_settings', null);
$this->proxy->set('last_applied_settings', null);
$this->detected_traefik_version = null;
$this->traefik_outdated_info = null;
$this->save();
if ($this->proxySet()) {
if ($async) {
+4 -28
View File
@@ -18,7 +18,6 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
#[OA\Schema(
@@ -1466,32 +1465,6 @@ class Service extends BaseModel
return null;
}
public function taskLink($task_uuid)
{
if (data_get($this, 'environment.project.uuid')) {
$route = route('project.service.scheduled-tasks', [
'project_uuid' => data_get($this, 'environment.project.uuid'),
'environment_uuid' => data_get($this, 'environment.uuid'),
'service_uuid' => data_get($this, 'uuid'),
'task_uuid' => $task_uuid,
]);
$settings = InstanceSettings::get();
if (data_get($settings, 'fqdn')) {
$url = Url::fromString($route);
$url = $url->withPort(null);
$fqdn = data_get($settings, 'fqdn');
$fqdn = str_replace(['http://', 'https://'], '', $fqdn);
$url = $url->withHost($fqdn);
return $url->__toString();
}
return $route;
}
return null;
}
public function documentation()
{
$services = get_service_templates();
@@ -1507,7 +1480,10 @@ class Service extends BaseModel
{
try {
$services = get_service_templates();
$serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value();
if (blank($this->service_type)) {
return null;
}
$serviceName = $this->service_type;
$service = data_get($services, $serviceName, []);
$port = data_get($service, 'port');
+63 -3
View File
@@ -201,7 +201,7 @@ class ServiceApplication extends BaseModel
}
/**
* Return the public URLs with their persisted internal port overrides.
* Return editable URLs with persisted overrides or legacy embedded ports.
*/
protected function url(): Attribute
{
@@ -220,7 +220,7 @@ class ServiceApplication extends BaseModel
$port = $overrides[$canonical] ?? null;
if ($port === null) {
return $canonical;
return $url;
}
$parts = DomainUrlParts::split($canonical);
@@ -366,7 +366,7 @@ class ServiceApplication extends BaseModel
}
$dockerCompose = Yaml::parse($dockerComposeRaw);
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
$serviceConfig = $dockerCompose['services'][$this->name] ?? null;
if (! $serviceConfig) {
return $this->service->getRequiredPort();
}
@@ -417,9 +417,21 @@ class ServiceApplication extends BaseModel
return $portFound;
}
$composePort = firstDockerComposeServicePort($serviceConfig);
if ($composePort !== null) {
return $composePort;
}
// HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT
// suffix), such as WordPress, inherit the one-click template `# port:`.
if ($declaresHttpUrl) {
if (blank($this->service->service_type)) {
$savedPort = $this->getSavedLegacyRoutingPort($serviceConfig);
if ($savedPort !== null) {
return $savedPort;
}
}
return $this->service->getRequiredPort();
}
@@ -428,4 +440,52 @@ class ServiceApplication extends BaseModel
return null;
}
}
/**
* Preserve only an unambiguous upstream from this legacy container's saved labels.
*/
private function getSavedLegacyRoutingPort(array $serviceConfig): ?int
{
$savedCompose = Yaml::parse($this->service->docker_compose ?? '');
$savedService = $savedCompose['services'][$this->name] ?? null;
$image = $serviceConfig['image'] ?? null;
if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) {
return null;
}
$labels = $savedService['labels'] ?? [];
if (! is_array($labels)) {
return null;
}
$ports = [];
foreach ($labels as $key => $value) {
if (is_int($key)) {
if (! is_string($value)) {
return null;
}
[$key, $value] = array_pad(explode('=', $value, 2), 2, null);
}
if (preg_match('/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/', $key)) {
$port = $value;
} elseif (preg_match('/^caddy(?:_\d+)?\..*reverse_proxy$/', $key)) {
if (! is_string($value) || ! preg_match('/^\{\{upstreams ([0-9]+)\}\}$/', $value, $matches)) {
return null;
}
$port = $matches[1];
} else {
continue;
}
if ((! is_string($port) && ! is_int($port)) || ! preg_match('/^[0-9]+$/', (string) $port) || (int) $port < 1 || (int) $port > 65535) {
return null;
}
$ports[] = (int) $port;
}
$ports = array_values(array_unique($ports));
return count($ports) === 1 ? $ports[0] : null;
}
}
+1 -2
View File
@@ -2,13 +2,12 @@
namespace App\Models;
use App\Traits\HasRestartLimit;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class ServiceDatabase extends BaseModel
{
use HasFactory, HasRestartLimit, SoftDeletes;
use HasFactory, SoftDeletes;
protected $fillable = [
'service_id',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,12 +14,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
'name',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -16,9 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+1 -4
View File
@@ -6,7 +6,6 @@ use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -15,12 +14,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
'name',
@@ -21,7 +21,7 @@ class ApiTokenExpiringNotification extends CustomEmailNotification
$this->onQueue('high');
$this->tokenName = $token->name;
$this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? '';
$this->manageUrl = route('security.api-tokens');
$this->manageUrl = base_url().'/security/api-tokens';
}
public function via(object $notifiable): array
@@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification
color: SlackMessage::warningColor(),
);
}
public function toWebhook(): array
{
return [
'success' => false,
'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.",
'event' => 'api_token_expiring',
'token_name' => $this->tokenName,
'expires_at' => $this->expiresAt,
'url' => $this->manageUrl,
];
}
}
@@ -2,8 +2,11 @@
namespace App\Notifications\Application;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\BaseModel;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
@@ -49,14 +52,19 @@ class RestartLimitReached extends CustomEmailNotification
if (str($this->fqdn)->explode(',')->count() > 1) {
$this->fqdn = str($this->fqdn)->explode(',')->first();
}
$service = data_get($resource, 'service');
$this->resource_url = match (true) {
method_exists($this->resource, 'link') => $this->resource->link(),
$resource instanceof ApplicationPreview => $resource->application->link(),
is_object($service) && method_exists($service, 'link') => $service->link(),
default => null,
$this->resource_url = $this->resolveResourceUrl($resource);
}
private function resolveResourceUrl(BaseModel $resource): string
{
[$type, $uuid] = match (true) {
$resource instanceof Application => ['application', $resource->uuid],
$resource instanceof ApplicationPreview => ['application', $resource->application->uuid],
$resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => ['service', $resource->service->uuid],
default => ['database', $resource->uuid],
};
$this->resource_url ??= base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}";
return base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/{$type}/{$uuid}";
}
public function via(object $notifiable): array
@@ -9,6 +9,7 @@ use App\Notifications\Application\RestartLimitReached;
use App\Notifications\Application\StatusChanged;
use App\Notifications\Container\ContainerRestarted;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupMissing;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
@@ -17,6 +18,7 @@ use App\Notifications\Server\DockerCleanupSuccess;
use App\Notifications\Server\HighDiskUsage;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\ServerPatchCheck;
use App\Notifications\Server\TraefikVersionOutdated;
use App\Notifications\Server\Unreachable;
class TelegramChannel
@@ -39,7 +41,8 @@ class TelegramChannel
RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id,
BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id,
BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id,
BackupFailed::class,
BackupMissing::class => $settings->telegram_notifications_backup_failure_thread_id,
TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id,
TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id,
@@ -50,7 +53,7 @@ class TelegramChannel
Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id,
Reachable::class => $settings->telegram_notifications_server_reachable_thread_id,
ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id,
TraefikVersionOutdated::class => $settings->telegram_notifications_traefik_outdated_thread_id,
default => null,
};
@@ -0,0 +1,83 @@
<?php
namespace App\Notifications\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
class BackupMissing extends CustomEmailNotification
{
public string $databaseName;
public function __construct(public ScheduledDatabaseBackup $backup, public ?Carbon $lastExecutionAt)
{
$this->onQueue('high');
$this->databaseName = $backup->database?->name ?? $backup->description ?? $backup->uuid;
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('backup_failure');
}
public function toMail(): MailMessage
{
return (new MailMessage)
->subject("Coolify: [ACTION REQUIRED] No recent backup for {$this->databaseName}")
->view('emails.backup-missing', $this->messageData());
}
public function toDiscord(): DiscordMessage
{
return new DiscordMessage(
title: ':warning: Scheduled database backup missing',
description: $this->description(),
color: DiscordMessage::errorColor(),
isCritical: true,
);
}
public function toTelegram(): array
{
return ['message' => 'Coolify: '.$this->description()];
}
public function toPushover(): PushoverMessage
{
return new PushoverMessage(title: 'Scheduled database backup missing', level: 'error', message: $this->description());
}
public function toSlack(): SlackMessage
{
return new SlackMessage(title: 'Scheduled database backup missing', description: $this->description(), color: SlackMessage::errorColor());
}
public function toWebhook(): array
{
return array_merge($this->messageData(), [
'success' => false,
'message' => 'Scheduled database backup missing',
'event' => 'backup_missing',
'backup_uuid' => $this->backup->uuid,
]);
}
private function description(): string
{
return "The enabled backup schedule for {$this->databaseName} has produced no executions in the last {$this->backup->missing_backup_notification_days} day(s).";
}
private function messageData(): array
{
return [
'database_name' => $this->databaseName,
'days' => $this->backup->missing_backup_notification_days,
'last_execution_at' => $this->lastExecutionAt?->toDateTimeString(),
];
}
}
@@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue
color: SlackMessage::infoColor(),
);
}
public function toWebhook(): array
{
return [
'success' => true,
'message' => $this->message,
'event' => 'general',
'url' => base_url(),
];
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace Illuminate\Notifications;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
interface Notification
{
public function toMail(SendsEmail $notifiable): MailMessage;
public function toPushover(): PushoverMessage;
public function toDiscord(): DiscordMessage;
public function toSlack(): SlackMessage;
public function toTelegram();
}
@@ -2,6 +2,7 @@
namespace App\Notifications\ScheduledTask;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
@@ -16,10 +17,10 @@ class TaskFailed extends CustomEmailNotification
public function __construct(public ScheduledTask $task, public string $output)
{
$this->onQueue('high');
if ($task->application) {
$this->url = $task->application->taskLink($task->uuid);
} elseif ($task->service) {
$this->url = $task->service->taskLink($task->uuid);
$resource = $task->application ?? $task->service;
if ($resource) {
$type = $resource instanceof Application ? 'application' : 'service';
$this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}";
}
}
@@ -2,6 +2,7 @@
namespace App\Notifications\ScheduledTask;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
@@ -16,10 +17,10 @@ class TaskSuccess extends CustomEmailNotification
public function __construct(public ScheduledTask $task, public string $output)
{
$this->onQueue('high');
if ($task->application) {
$this->url = $task->application->taskLink($task->uuid);
} elseif ($task->service) {
$this->url = $task->service->taskLink($task->uuid);
$resource = $task->application ?? $task->service;
if ($resource) {
$type = $resource instanceof Application ? 'application' : 'service';
$this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}";
}
}
@@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
return [
'success' => false,
'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.",
'event' => 'server_force_disabled',
'server_name' => $this->server->name,
'server_uuid' => $this->server->uuid,
'url' => base_url().'/server/'.$this->server->uuid,
];
}
}
+12
View File
@@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification
color: SlackMessage::successColor()
);
}
public function toWebhook(): array
{
return [
'success' => true,
'message' => "Server ({$this->server->name}) enabled again!",
'event' => 'server_force_enabled',
'server_name' => $this->server->name,
'server_uuid' => $this->server->uuid,
'url' => base_url().'/server/'.$this->server->uuid,
];
}
}
@@ -17,8 +17,7 @@ class HetznerDeletionFailed extends CustomEmailNotification
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('hetzner_deletion_failed');
return $notifiable->getEnabledChannels('hetzner_deletion_failure');
}
public function toMail(): MailMessage
@@ -66,4 +65,16 @@ class HetznerDeletionFailed extends CustomEmailNotification
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
return [
'success' => false,
'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.",
'event' => 'hetzner_deletion_failed',
'hetzner_server_id' => $this->hetznerServerId,
'error' => $this->errorMessage,
'url' => base_url().'/servers',
];
}
}
+17 -34
View File
@@ -7,7 +7,6 @@ use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Collection;
use Spatie\Url\Url;
class SslExpirationNotification extends CustomEmailNotification
{
@@ -19,39 +18,9 @@ class SslExpirationNotification extends CustomEmailNotification
{
$this->onQueue('high');
$this->resources = collect($resources);
// Collect URLs for each resource
$this->resources->each(function ($resource) {
if (data_get($resource, 'environment.project.uuid')) {
$routeName = match ($resource->type()) {
'application' => 'project.application.configuration',
'database' => 'project.database.configuration',
'service' => 'project.service.configuration',
default => null
};
if ($routeName) {
$route = route($routeName, [
'project_uuid' => data_get($resource, 'environment.project.uuid'),
'environment_uuid' => data_get($resource, 'environment.uuid'),
$resource->type().'_uuid' => data_get($resource, 'uuid'),
]);
$settings = instanceSettings();
if (data_get($settings, 'fqdn')) {
$url = Url::fromString($route);
$url = $url->withPort(null);
$fqdn = data_get($settings, 'fqdn');
$fqdn = str_replace(['http://', 'https://'], '', $fqdn);
$url = $url->withHost($fqdn);
$this->urls[$resource->name] = $url->__toString();
} else {
$this->urls[$resource->name] = $route;
}
}
}
});
$this->urls = $this->resources->mapWithKeys(fn ($resource) => [
$resource->name => base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/database/{$resource->uuid}",
])->all();
}
public function via(object $notifiable): array
@@ -148,4 +117,18 @@ class SslExpirationNotification extends CustomEmailNotification
color: SlackMessage::warningColor()
);
}
public function toWebhook(): array
{
$resourceNames = $this->resources->pluck('name');
return [
'success' => false,
'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.",
'event' => 'ssl_certificate_renewal',
'resources' => $resourceNames->values()->all(),
'urls' => $this->urls,
'url' => base_url(),
];
}
}
+2 -7
View File
@@ -90,11 +90,8 @@ class DatabaseBackupFileValidator
public static function containsPostgresqlProgramExecution(string $sql): bool
{
$requireStatementBoundary = true;
if (str_starts_with($sql, 'PGDMP')) {
$sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql;
$requireStatementBoundary = false;
return false;
}
$withoutComments = self::stripSqlComments($sql);
@@ -103,9 +100,7 @@ class DatabaseBackupFileValidator
return true;
}
$copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b';
return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string
+10 -1
View File
@@ -58,7 +58,16 @@ trait HasNoindexDomains
private function currentDomains(): Collection
{
return collect(ValidationPatterns::applicationDomainList($this->fqdn))
$domains = collect(ValidationPatterns::applicationDomainList($this->fqdn));
$composeDomains = json_decode((string) ($this->getAttributes()['docker_compose_domains'] ?? null), true);
if (is_array($composeDomains)) {
foreach ($composeDomains as $entry) {
$domains->push(...ValidationPatterns::applicationDomainList(composeDomainEntryString($entry)));
}
}
return $domains
->map(fn (string $domain) => $this->normalizeNoindexDomain($domain));
}
+51
View File
@@ -601,6 +601,57 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
return $labels->sort();
}
function firstDockerComposeServicePort(mixed $service): ?int
{
$portDefinitions = collect(data_get($service, 'expose', []))
->merge(data_get($service, 'ports', []));
foreach ($portDefinitions as $definition) {
$protocol = is_array($definition)
? data_get($definition, 'protocol', 'tcp')
: (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp');
if ($protocol !== 'tcp') {
continue;
}
$port = is_array($definition)
? data_get($definition, 'target')
: str((string) $definition)->before('/')->afterLast(':')->value();
if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) {
return (int) $port;
}
}
return null;
}
function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int
{
return dockerComposeServicePorts($compose, $serviceName)[0] ?? null;
}
function dockerComposeServicePorts(?string $compose, ?string $serviceName): array
{
if (blank($compose) || blank($serviceName)) {
return [];
}
try {
$services = data_get(Yaml::parse($compose), 'services', []);
} catch (Throwable) {
return [];
}
$service = is_array($services) ? ($services[$serviceName] ?? []) : [];
return collect(data_get($service, 'expose', []))
->merge(data_get($service, 'ports', []))
->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]]))
->filter(fn ($port) => $port !== null)
->unique()->values()->all();
}
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([]);
+20
View File
@@ -443,6 +443,26 @@ function getComposeServiceDomainString(array|Collection $domains, string $servic
return $matches[0]['domain'];
}
/**
* Determine whether a compose service already has a domain-map entry, including
* an explicitly empty entry left when a user removes its generated domain.
*
* @param array<string, mixed>|Collection<string, mixed> $domains
*/
function hasComposeServiceDomainEntry(array|Collection $domains, string $serviceName): bool
{
$normalized = normalizeComposeServiceName($serviceName);
foreach (collect($domains)->keys() as $key) {
$key = (string) $key;
if ($key === $serviceName || normalizeComposeServiceName($key) === $normalized) {
return true;
}
}
return false;
}
function composeDomainEntryString(mixed $entry): ?string
{
if (is_object($entry)) {
+8 -43
View File
@@ -525,8 +525,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services));
if ($originalServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
$domainExists = getComposeServiceDomainString($domains, $originalServiceName);
if (is_null($domainExists)) {
if (! hasComposeServiceDomainEntry($domains, $originalServiceName)) {
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
@@ -648,12 +647,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Only add domain if the service exists
if ($composeServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
$domainExists = getComposeServiceDomainString($domains, $composeServiceName);
// Update domain using URL with port if applicable
$domainValue = $port ? $urlWithPort : $url;
if (is_null($domainExists)) {
if (! hasComposeServiceDomainEntry($domains, $composeServiceName)) {
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$composeServiceName,
@@ -1357,8 +1354,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$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;
$onlyPort = firstDockerComposeServicePort($service);
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
@@ -1565,7 +1561,6 @@ function serviceParser(Service $resource): Collection
$envComments = extractYamlEnvironmentComments($compose);
$server = data_get($resource, 'server');
$allServices = get_service_templates();
try {
$yaml = Yaml::parse($compose);
@@ -1698,22 +1693,7 @@ function serviceParser(Service $resource): Collection
$containerName = "$serviceName-{$resource->uuid}";
if ($serviceName === 'registry') {
$tempServiceName = 'docker-registry';
} else {
$tempServiceName = $serviceName;
}
if (str(data_get($service, 'image'))->contains('glitchtip')) {
$tempServiceName = 'glitchtip';
}
if ($serviceName === 'supabase-kong') {
$tempServiceName = 'supabase';
}
$serviceDefinition = data_get($allServices, $tempServiceName);
$predefinedPort = data_get($serviceDefinition, 'port');
if ($serviceName === 'plausible') {
$predefinedPort = '8000';
}
$predefinedPort = $resource->getRequiredPort();
if ($migratedApp || $migratedDb) {
// Use the already determined migrated service
@@ -2083,22 +2063,7 @@ function serviceParser(Service $resource): Collection
$containerName = "$serviceName-{$resource->uuid}";
if ($serviceName === 'registry') {
$tempServiceName = 'docker-registry';
} else {
$tempServiceName = $serviceName;
}
if (str(data_get($service, 'image'))->contains('glitchtip')) {
$tempServiceName = 'glitchtip';
}
if ($serviceName === 'supabase-kong') {
$tempServiceName = 'supabase';
}
$serviceDefinition = data_get($allServices, $tempServiceName);
$predefinedPort = data_get($serviceDefinition, 'port');
if ($serviceName === 'plausible') {
$predefinedPort = '8000';
}
$predefinedPort = $resource->getRequiredPort();
if ($migratedApp || $migratedDb) {
// Use the already determined migrated service
@@ -2641,7 +2606,7 @@ function serviceParser(Service $resource): Collection
? data_get($originalResource, 'redirect')
: 'both';
$onlyPort = $originalResource instanceof ServiceApplication
? ($originalResource->getRequiredPort() ?? $predefinedPort)
? $originalResource->getRequiredPort()
: $predefinedPort;
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
@@ -2676,7 +2641,7 @@ function serviceParser(Service $resource): Collection
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
predefinedPort: $predefinedPort,
predefinedPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
@@ -2709,7 +2674,7 @@ function serviceParser(Service $resource): Collection
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
predefinedPort: $predefinedPort,
predefinedPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
+7 -26
View File
@@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string
return null;
}
$baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl();
$baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl();
return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version;
}
@@ -2486,7 +2486,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
$allServices = get_service_templates();
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
$topLevelConfigs = collect(data_get($yaml, 'configs', []));
@@ -2512,25 +2511,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
$topLevelVolumes = collect($tempTopLevelVolumes);
}
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) {
// Workarounds for beta users.
if ($serviceName === 'registry') {
$tempServiceName = 'docker-registry';
} else {
$tempServiceName = $serviceName;
}
if (str(data_get($service, 'image'))->contains('glitchtip')) {
$tempServiceName = 'glitchtip';
}
if ($serviceName === 'supabase-kong') {
$tempServiceName = 'supabase';
}
$serviceDefinition = data_get($allServices, $tempServiceName);
$predefinedPort = data_get($serviceDefinition, 'port');
if ($serviceName === 'plausible') {
$predefinedPort = '8000';
}
// End of workarounds for beta users.
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $envComments) {
$predefinedPort = $resource->getRequiredPort();
$serviceVolumes = collect(data_get($service, 'volumes', []));
$servicePorts = collect(data_get($service, 'ports', []));
$serviceNetworks = collect(data_get($service, 'networks', []));
@@ -3107,7 +3089,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
? ($savedService->domain_port_overrides ?? [])
: [];
$onlyPort = $savedService instanceof ServiceApplication
? ($savedService->getRequiredPort() ?? $predefinedPort)
? $savedService->getRequiredPort()
: $predefinedPort;
if ($shouldGenerateLabelsExactly) {
switch ($resource->server->proxyType()) {
@@ -3139,7 +3121,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
service_name: $serviceName,
image: data_get($service, 'image'),
onlyPort: $onlyPort,
predefinedPort: $predefinedPort,
predefinedPort: $onlyPort,
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
@@ -3172,7 +3154,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
service_name: $serviceName,
image: data_get($service, 'image'),
onlyPort: $onlyPort,
predefinedPort: $predefinedPort,
predefinedPort: $onlyPort,
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
@@ -3915,8 +3897,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$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;
$onlyPort = firstDockerComposeServicePort($service);
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
+7 -2
View File
@@ -59,13 +59,18 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
return $line;
}
// Negation belongs to the shell, before the elevated command.
if (preg_match('/^\s*!\s+/', $line)) {
return preg_replace('/^(\s*(?:!\s+)+)/', '$1sudo ', $line);
}
// Check all keywords with word boundary matching
// Match keyword followed by space, semicolon, or end of line
foreach ($bashKeywords as $keyword) {
if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) {
// Special handling for 'if' - insert sudo after 'if '
// Keep any shell negation before sudo in the condition.
if ($keyword === 'if') {
return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line);
return preg_replace('/^(\s*if\s+(?:!\s+)*)/', '$1sudo ', $line);
}
return $line;
+2 -3
View File
@@ -2,9 +2,9 @@
return [
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.3.15',
'version' => env('COOLIFY_VERSION') ?: '4.3.18',
'helper_version' => '1.0.16',
'realtime_version' => '1.0.18',
'realtime_version' => '1.0.19',
'railpack_version' => '0.23.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@@ -14,7 +14,6 @@ return [
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'avatar_cdn_url' => env('AVATAR_CDN_URL'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'),
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->unsignedInteger('missing_backup_notification_days')->default(0);
$table->timestamp('missing_backup_notification_sent_at')->nullable();
$table->timestamp('last_execution_at')->nullable();
});
}
public function down(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->dropColumn([
'missing_backup_notification_days',
'missing_backup_notification_sent_at',
'last_execution_at',
]);
});
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const STANDALONE_DATABASE_TABLES = [
'standalone_postgresqls',
'standalone_redis',
'standalone_mongodbs',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_keydbs',
'standalone_dragonflies',
'standalone_clickhouses',
];
public function up(): void
{
foreach (self::STANDALONE_DATABASE_TABLES as $tableName) {
Schema::table($tableName, function (Blueprint $table) {
$table->dropColumn(['max_restart_count', 'restart_limit_reached']);
});
}
Schema::table('service_databases', function (Blueprint $table) {
$table->dropColumn([
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
]);
});
}
public function down(): void
{
foreach (self::STANDALONE_DATABASE_TABLES as $tableName) {
Schema::table($tableName, function (Blueprint $table) {
$table->integer('max_restart_count')->default(10);
$table->boolean('restart_limit_reached')->default(false);
});
}
Schema::table('service_databases', function (Blueprint $table) {
$table->integer('restart_count')->default(0);
$table->integer('max_restart_count')->default(10);
$table->boolean('restart_limit_reached')->default(false);
$table->timestamp('last_restart_at')->nullable();
$table->string('last_restart_type', 10)->nullable();
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('image_cdn_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('image_cdn_url');
});
}
};
+3 -3
View File
@@ -10,14 +10,14 @@ class TeamSeeder extends Seeder
{
public function run(): void
{
$normal_user_in_root_team = User::find(1);
$normal_user_in_root_team = User::where('email', 'test2@example.com')->firstOrFail();
$root_user_personal_team = Team::find(0);
$root_user_personal_team->description = 'The root team';
$root_user_personal_team->save();
$normal_user_in_root_team->teams()->attach($root_user_personal_team);
$normal_user_not_in_root_team = User::find(2);
$normal_user_in_root_team_personal_team = Team::find(1);
$normal_user_not_in_root_team = User::where('email', 'test3@example.com')->firstOrFail();
$normal_user_in_root_team_personal_team = $normal_user_in_root_team->teams()->where('personal_team', true)->wherePivot('role', 'owner')->firstOrFail();
$normal_user_not_in_root_team->teams()->attach($normal_user_in_root_team_personal_team, ['role' => 'admin']);
}
}
+1
View File
@@ -22,5 +22,6 @@ class UserSeeder extends Seeder
'name' => 'Normal User (not in root team)',
'email' => 'test3@example.com',
]);
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18'
image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.19'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
+1 -1
View File
@@ -97,7 +97,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.19'
pull_policy: always
container_name: coolify-realtime
restart: always
+2 -1
View File
@@ -8,6 +8,7 @@ import {
extractSshArgs,
extractTargetHost,
extractTimeout,
getTerminalProcessEnv,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
sanitizeSshArgs,
@@ -401,7 +402,7 @@ async function handleCommand(ws, command, userId) {
cols: 80,
rows: 30,
cwd: process.env.HOME,
env: {},
env: getTerminalProcessEnv(),
};
// NOTE: - Initiates a process within the Terminal container
@@ -1,5 +1,13 @@
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
export function getTerminalProcessEnv(environment = process.env) {
return {
PATH: environment.PATH || DEFAULT_TERMINAL_PATH,
};
}
export function getTerminalSessionTimeout() {
return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
}
@@ -4,6 +4,7 @@ import {
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
extractSshArgs,
extractTargetHost,
getTerminalProcessEnv,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
@@ -11,6 +12,32 @@ import {
validateSshArgs,
} from './terminal-utils.js';
test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => {
assert.deepEqual(getTerminalProcessEnv({
PATH: '/usr/local/bin:/usr/bin:/bin',
APP_KEY: 'must-not-be-inherited',
}), {
PATH: '/usr/local/bin:/usr/bin:/bin',
});
});
test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => {
assert.deepEqual(getTerminalProcessEnv({
APP_KEY: 'must-not-be-inherited',
}), {
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
});
test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => {
assert.deepEqual(getTerminalProcessEnv({
PATH: '',
APP_KEY: 'must-not-be-inherited',
}), {
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
});
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"

Some files were not shown because too many files have changed in this diff Show More