mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 08:50:35 -04:00
chore: merge main into next (#11678)
This commit is contained in:
@@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444
|
||||
BUNNY_API_KEY=
|
||||
# For asset uploads
|
||||
BUNNY_STORAGE_API_KEY=
|
||||
AVATAR_CDN_URL=
|
||||
|
||||
@@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify
|
||||
PUSHER_APP_ID=coolify
|
||||
PUSHER_APP_KEY=coolify
|
||||
PUSHER_APP_SECRET=coolify
|
||||
AVATAR_CDN_URL=
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Sync main to next
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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}.'";
|
||||
|
||||
@@ -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,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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'.");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'retry_count' => 'integer',
|
||||
'duration' => 'decimal:2',
|
||||
'duration' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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'),
|
||||
|
||||
+28
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
+28
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ class UserSeeder extends Seeder
|
||||
'name' => 'Normal User (not in root team)',
|
||||
'email' => 'test3@example.com',
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user