Merge remote-tracking branch 'origin/next' into api-application-preview-deployments

This commit is contained in:
Andras Bacsai
2026-07-07 12:38:37 +02:00
693 changed files with 27662 additions and 7249 deletions
@@ -143,8 +143,6 @@ class StartDatabaseProxy
)
);
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
return;
}
@@ -0,0 +1,16 @@
<?php
namespace App\Actions\Destination;
use App\Models\StandaloneDocker;
class RemoveStandaloneDockerNetwork
{
public function handle(StandaloneDocker $destination): void
{
$safeNetwork = escapeshellarg($destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class CleanupDocker
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
-21
View File
@@ -26,22 +26,14 @@ class DeleteServer
);
}
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
}
ray('force deleting server from Coolify', ['server_id' => $server->id]);
try {
$server->forceDelete();
} catch (\Throwable $e) {
ray('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
logger()->error('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
@@ -66,10 +58,6 @@ class DeleteServer
}
if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return;
}
@@ -77,16 +65,7 @@ class DeleteServer
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
ray('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Log the error but don't prevent the server from being deleted from Coolify
logger()->error('Failed to delete server from Hetzner', [
+1 -1
View File
@@ -26,7 +26,7 @@ class StartSentinel
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}
+5 -1
View File
@@ -118,10 +118,14 @@ class UpdateCoolify
{
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl();
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
'bash /data/coolify/source/upgrade.sh '.
escapeshellarg($this->latestVersion).' '.
escapeshellarg($latestHelperImageVersion).' '.
escapeshellarg($registryUrl),
], $this->server);
}
}
+5 -4
View File
@@ -5,6 +5,7 @@ namespace App\Actions\Stripe;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Collection;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CancelSubscription
@@ -21,7 +22,7 @@ class CancelSubscription
$this->isDryRun = $isDryRun;
if (! $isDryRun && isCloud()) {
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = app(StripeClient::class);
}
}
@@ -64,7 +65,7 @@ class CancelSubscription
];
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$subscriptions = $this->getSubscriptionsPreview();
$verified = collect();
@@ -88,7 +89,7 @@ class CancelSubscription
'reason' => "Status in Stripe: {$stripeSubscription->status}",
]);
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
// Subscription doesn't exist in Stripe
$notFound->push([
'subscription' => $subscription,
@@ -181,7 +182,7 @@ class CancelSubscription
return false;
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripe->subscriptions->cancel($subscriptionId, []);
// Update local record if exists
@@ -3,6 +3,7 @@
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd
@@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@@ -47,7 +48,7 @@ class CancelSubscriptionAtPeriodEnd
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
+6 -4
View File
@@ -3,6 +3,8 @@
namespace App\Actions\Stripe;
use App\Models\Team;
use Carbon\Carbon;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class RefundSubscription
@@ -13,7 +15,7 @@ class RefundSubscription
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@@ -39,7 +41,7 @@ class RefundSubscription
try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
return $this->ineligible('Subscription not found in Stripe.');
}
@@ -49,7 +51,7 @@ class RefundSubscription
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
$startDate = Carbon::createFromTimestamp($stripeSubscription->start_date);
$daysSinceStart = (int) $startDate->diffInDays(now());
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
@@ -130,7 +132,7 @@ class RefundSubscription
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
+3 -2
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class ResumeSubscription
@@ -11,7 +12,7 @@ class ResumeSubscription
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@@ -43,7 +44,7 @@ class ResumeSubscription
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
@@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
$this->stripe = $stripe ?? app(StripeClient::class);
}
/**
@@ -4,6 +4,8 @@ namespace App\Console\Commands\Cloud;
use App\Models\Team;
use Illuminate\Console\Command;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class CloudFixSubscription extends Command
{
@@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe);
@@ -111,7 +113,7 @@ class CloudFixSubscription extends Command
/**
* Fix canceled subscriptions in the database
*/
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
private function fixCanceledSubscriptions(StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$checkOne = $this->option('one');
@@ -220,7 +222,7 @@ class CloudFixSubscription extends Command
break;
}
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++;
@@ -326,7 +328,7 @@ class CloudFixSubscription extends Command
/**
* Verify all active subscriptions against Stripe API
*/
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
private function verifyAllActiveSubscriptions(StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified');
@@ -570,7 +572,7 @@ class CloudFixSubscription extends Command
break;
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
} catch (InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
@@ -730,7 +732,7 @@ class CloudFixSubscription extends Command
/**
* Search for subscriptions by customer ID
*/
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
{
try {
$subscriptions = $stripe->subscriptions->all([
@@ -770,7 +772,7 @@ class CloudFixSubscription extends Command
/**
* Search for subscriptions by team member emails
*/
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
{
$this->line(' → Searching by team member emails...');
+1
View File
@@ -18,6 +18,7 @@ use Exception;
use Illuminate\Console\Command;
use Illuminate\Mail\Message;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Str;
use Mail;
use function Laravel\Prompts\confirm;
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Generate;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
class Services extends Command
@@ -77,6 +78,7 @@ class Services extends Command
'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
];
if ($port = $data->get('port')) {
@@ -99,6 +101,26 @@ class Services extends Command
return $payload;
}
private function templateLastUpdatedAt(string $file): ?string
{
$process = Process::path(base_path())->run([
'git',
'log',
'-1',
'--format=%cI',
'--',
"templates/compose/{$file}",
]);
if ($process->failed()) {
return null;
}
$timestamp = trim($process->output());
return $timestamp === '' ? null : $timestamp;
}
private function generateServiceTemplatesWithFqdn(): void
{
$serviceTemplatesWithFqdn = collect(array_merge(
@@ -155,6 +177,7 @@ class Services extends Command
'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
];
if ($port = $data->get('port')) {
@@ -232,6 +255,7 @@ class Services extends Command
'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
];
if ($port = $data->get('port')) {
@@ -30,7 +30,6 @@ use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
class ApplicationsController extends Controller
{
@@ -59,6 +58,10 @@ class ApplicationsController extends Controller
]);
}
if ($application->is_shown_once ?? false) {
$application->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($application);
}
@@ -954,6 +957,10 @@ class ApplicationsController extends Controller
}
$serverUuid = $request->server_uuid;
$fqdn = $request->domains;
if ($request->has('domains') && is_string($request->domains)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $fqdn);
}
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
@@ -1034,7 +1041,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1090,7 +1097,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -1140,15 +1147,15 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson;
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
}
$repository_url_parsed = Url::fromString($request->git_repository);
$git_host = $repository_url_parsed->getHost();
if ($git_host === 'github.com') {
$application->source_type = GithubApp::class;
$application->source_id = GithubApp::find(0)->id;
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
}
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
$application->fqdn = $fqdn;
$application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass();
@@ -1203,7 +1210,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1246,7 +1253,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@@ -1335,7 +1342,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -1385,7 +1392,7 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson;
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
}
$application->fqdn = $fqdn;
$application->git_repository = str($gitRepository)->trim()->toString();
@@ -1446,7 +1453,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1490,7 +1497,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
@@ -1552,7 +1559,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -1602,7 +1609,7 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson;
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
}
$application->fqdn = $fqdn;
$application->private_key_id = $privateKey->id;
@@ -1659,7 +1666,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1705,7 +1712,7 @@ class ApplicationsController extends Controller
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', 'dockerfile-'.new Cuid2);
$request->offsetSet('name', 'dockerfile-'.new_public_id());
}
$return = $this->validateDataApplications($request, $server);
@@ -1783,7 +1790,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1827,7 +1834,7 @@ class ApplicationsController extends Controller
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', 'docker-image-'.new Cuid2);
$request->offsetSet('name', 'docker-image-'.new_public_id());
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -1906,7 +1913,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -2036,6 +2043,13 @@ class ApplicationsController extends Controller
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
@@ -2099,8 +2113,9 @@ class ApplicationsController extends Controller
], 400);
}
$lines = $request->query->get('lines', 100) ?: 100;
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines);
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
@@ -2307,6 +2322,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'],
],
)
),
@@ -2392,7 +2408,7 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'include_source_commit_in_build'];
$validationRules = [
'name' => 'string|max:255',
@@ -2402,12 +2418,13 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'is_preview_deployments_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
'http_basic_auth_password' => 'string',
'include_source_commit_in_build' => 'boolean',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@@ -2507,29 +2524,7 @@ class ApplicationsController extends Controller
$requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
@@ -2537,6 +2532,9 @@ class ApplicationsController extends Controller
'errors' => $errors,
], 422);
}
$domains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $domains);
$urls = collect(ValidationPatterns::applicationDomainList($domains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -2581,7 +2579,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -2645,6 +2643,7 @@ class ApplicationsController extends Controller
$useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
$includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build');
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -2688,6 +2687,10 @@ class ApplicationsController extends Controller
$application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
$application->settings->save();
}
if ($request->has('include_source_commit_in_build')) {
$application->settings->include_source_commit_in_build = $includeSourceCommitInBuild;
$application->settings->save();
}
removeUnnecessaryFieldsFromRequest($request);
$data = $request->only($allowedFields);
@@ -2712,7 +2715,7 @@ class ApplicationsController extends Controller
]);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -2907,8 +2910,12 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3131,12 +3138,18 @@ class ApplicationsController extends Controller
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
$item = collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
if ($item->has('key')) {
$item->put('key', ValidationPatterns::normalizeEnvironmentVariableKey((string) $item->get('key')));
}
return $item;
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3333,8 +3346,12 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3619,7 +3636,7 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -3641,7 +3658,7 @@ class ApplicationsController extends Controller
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force,
'instant_deploy' => $instant_deploy,
]);
@@ -3649,7 +3666,7 @@ class ApplicationsController extends Controller
return response()->json(
[
'message' => 'Deployment request queued.',
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
],
200
);
@@ -3817,7 +3834,7 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -3835,13 +3852,13 @@ class ApplicationsController extends Controller
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
]);
return response()->json(
[
'message' => 'Restart request queued.',
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
],
);
}
@@ -3888,36 +3905,16 @@ class ApplicationsController extends Controller
}
if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $normalizedDomains);
$urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -4296,10 +4293,11 @@ class ApplicationsController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -4323,7 +4321,7 @@ class ApplicationsController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -4354,6 +4352,14 @@ class ApplicationsController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -4376,12 +4382,50 @@ class ApplicationsController extends Controller
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -177,6 +177,7 @@ class CloudProviderTokensController extends Controller
if (is_null($token)) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return response()->json($this->removeSensitiveData($token));
}
@@ -243,6 +244,7 @@ class CloudProviderTokensController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [CloudProviderToken::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -394,6 +396,7 @@ class CloudProviderTokensController extends Controller
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('update', $token);
$token->update(array_intersect_key($body, array_flip($allowedFields)));
@@ -475,6 +478,7 @@ class CloudProviderTokensController extends Controller
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('delete', $token);
if ($token->hasServers()) {
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
@@ -545,9 +549,18 @@ class CloudProviderTokensController extends Controller
if (! $cloudToken) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('view', $cloudToken);
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
auditLog('api.cloud_token.validated', [
'team_id' => $teamId,
'cloud_token_uuid' => $cloudToken->uuid,
'cloud_token_name' => $cloudToken->name,
'provider' => $cloudToken->provider,
'valid' => $validation['valid'],
]);
return response()->json([
'valid' => $validation['valid'],
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
+179 -10
View File
@@ -2247,6 +2247,116 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Invalid database type requested.'], 400);
}
#[OA\Get(
summary: 'Get database logs.',
description: 'Get database logs by UUID.',
path: '/databases/{uuid}/logs',
operationId: 'get-database-logs-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get database logs by UUID.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id);
if ($containers->count() == 0) {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$container = $containers->first();
$status = getContainerStatus($database->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Delete(
summary: 'Delete',
description: 'Delete database by UUID.',
@@ -3133,8 +3243,12 @@ class DatabasesController extends Controller
$this->authorize('manageEnvironment', $database);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3281,8 +3395,12 @@ class DatabasesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3399,8 +3517,12 @@ class DatabasesController extends Controller
$this->authorize('manageEnvironment', $database);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3684,10 +3806,11 @@ class DatabasesController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -3711,7 +3834,7 @@ class DatabasesController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -3742,6 +3865,14 @@ class DatabasesController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -3764,12 +3895,50 @@ class DatabasesController extends Controller
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -15,7 +15,6 @@ use App\Models\Tag;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
class DeployController extends Controller
{
@@ -366,7 +365,7 @@ class DeployController extends Controller
$uuids = $request->input('uuid');
$tags = $request->input('tag');
$force = $request->input('force') ?? false;
$force = $request->boolean('force');
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
@@ -426,7 +425,7 @@ class DeployController extends Controller
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
} else {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
}
@@ -472,7 +471,7 @@ class DeployController extends Controller
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
}
$message = $message->merge($return_message);
}
@@ -511,7 +510,7 @@ class DeployController extends Controller
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $resource,
deployment_uuid: $deployment_uuid,
@@ -530,7 +529,7 @@ class DeployController extends Controller
'resource_type' => 'application',
'application_uuid' => $resource->uuid,
'application_name' => $resource->name,
'deployment_uuid' => $deployment_uuid?->toString(),
'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force,
'pull_request_id' => $pr,
]);
@@ -0,0 +1,239 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DestinationsController extends Controller
{
private function transform(StandaloneDocker|SwarmDocker $destination): array
{
return [
'uuid' => $destination->uuid,
'name' => $destination->name,
'network' => $destination->network,
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
'server_uuid' => $destination->server?->uuid,
'created_at' => $destination->created_at,
'updated_at' => $destination->updated_at,
];
}
/**
* Resolve the calling token's team id, or return an invalid-token response.
*/
private function teamIdOrAbort(): int|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return $teamId;
}
/**
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
* controller works on Coolify versions that pre-date that scope being added
* to the destination models (e.g. 4.0.0-beta.470).
*/
private function teamScopedDockers(int $teamId): array
{
return [
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
];
}
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
{
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$sets = $this->teamScopedDockers($teamId);
return response()->json(
$sets['standalone']->concat($sets['swarm'])
->map(fn ($destination) => $this->transform($destination))
->values()
);
}
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
->whereTeamId($teamId)
->whereUuid($server_uuid)
->firstOrFail();
$list = $server->standaloneDockers->concat($server->swarmDockers);
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
return response()->json($this->transform($destination));
}
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
$allowed = ['name', 'network', 'type'];
$validator = customApiValidator($request->all(), [
'name' => 'nullable|string|max:255',
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
'type' => 'nullable|in:standalone,swarm',
]);
$extra = array_diff(array_keys($request->all()), $allowed);
if ($validator->fails() || ! empty($extra)) {
$errors = $validator->errors();
if (! empty($extra)) {
foreach ($extra as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
$type = $request->input('type', $expectedType);
if ($type !== $expectedType) {
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
}
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
$this->authorize('create', $class);
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
if ($exists) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
try {
$destination = $class::create([
'name' => $name,
'network' => $request->input('network'),
'server_id' => $server->id,
]);
} catch (QueryException $exception) {
if ($this->isUniqueConstraintViolation($exception)) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
throw $exception;
}
auditLog('api.destination.created', [
'team_id' => $teamId,
'destination_uuid' => $destination->uuid,
'destination_name' => $destination->name,
'destination_type' => $type,
'server_uuid' => $server->uuid,
]);
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
}
private function isUniqueConstraintViolation(QueryException $exception): bool
{
$sqlState = $exception->errorInfo[0] ?? null;
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
return in_array($sqlState, ['23000', '23505'], true)
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
$this->authorize('delete', $destination);
// Guard against deleting destinations with attached resources. attachedTo()
// is recent on the destination models; fall back to a manual check for
// older Coolify versions (e.g. 4.0.0-beta.470).
if (method_exists($destination, 'attachedTo')) {
if ($destination->attachedTo()) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
} else {
$hasAttached = $destination->applications()->exists()
|| $destination->postgresqls()->exists()
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|| (method_exists($destination, 'services') && $destination->services()->exists());
if ($hasAttached) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
}
if ($destination instanceof StandaloneDocker) {
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
}
$destinationUuid = $destination->uuid;
$destinationName = $destination->name;
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
$serverUuid = $destination->server?->uuid;
$destination->delete();
auditLog('api.destination.deleted', [
'team_id' => $teamId,
'destination_uuid' => $destinationUuid,
'destination_name' => $destinationName,
'destination_type' => $destinationType,
'server_uuid' => $serverUuid,
]);
return response()->json(['message' => 'Deleted.']);
}
}
+25 -5
View File
@@ -129,7 +129,7 @@ class GithubController extends Controller
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
),
),
],
@@ -183,6 +183,7 @@ class GithubController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [GithubApp::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
@@ -204,10 +205,14 @@ class GithubController extends Controller
'is_system_wide',
];
$request->merge([
'organization' => normalizeGithubOrganization($request->input('organization')),
]);
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
@@ -251,7 +256,9 @@ class GithubController extends Controller
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'api_url' => filled($request->input('api_url'))
? $request->input('api_url')
: githubApiUrlFromHtmlUrl($request->input('html_url')),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
@@ -564,6 +571,7 @@ class GithubController extends Controller
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$this->authorize('update', $githubApp);
// Define allowed fields for update
$allowedFields = [
@@ -587,13 +595,17 @@ class GithubController extends Controller
$payload = $request->only($allowedFields);
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
// Validate the request
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string';
}
if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string';
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
}
if (isset($payload['api_url'])) {
$rules['api_url'] = ['url', new SafeExternalUrl];
@@ -637,6 +649,13 @@ class GithubController extends Controller
], 422);
}
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)
@@ -737,6 +756,7 @@ class GithubController extends Controller
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$this->authorize('delete', $githubApp);
// Check if the GitHub app is being used by any applications
if ($githubApp->applications->isNotEmpty()) {
@@ -116,6 +116,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -237,6 +238,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -336,6 +338,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -445,6 +448,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -550,6 +554,7 @@ class HetznerController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [Server::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -620,6 +625,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
// Validate private key
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
@@ -97,6 +97,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('view', $project);
$project->load(['environments']);
@@ -233,6 +234,7 @@ class ProjectController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [Project::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -385,6 +387,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('update', $project);
$project->update($request->only($allowedFields));
@@ -469,6 +472,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('delete', $project);
if (! $project->isEmpty()) {
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
@@ -652,6 +656,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('update', $project);
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
if ($existingEnvironment) {
@@ -746,6 +751,7 @@ class ProjectController extends Controller
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
$this->authorize('delete', $environment);
if (! $environment->isEmpty()) {
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
@@ -110,6 +110,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
$this->authorize('view', $key);
return response()->json($this->removeSensitiveData($key));
}
@@ -176,6 +177,7 @@ class SecurityController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [PrivateKey::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
@@ -338,6 +340,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
$this->authorize('update', $foundKey);
$foundKey->update($request->only($allowedFields));
auditLog('api.private_key.updated', [
@@ -421,6 +424,7 @@ class SecurityController extends Controller
if (is_null($key)) {
return response()->json(['message' => 'Private Key not found.'], 404);
}
$this->authorize('delete', $key);
if ($key->isInUse()) {
return response()->json([
@@ -97,12 +97,12 @@ class SentinelController extends Controller
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
}
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
}
return response()->json(['message' => 'ok'], 200);
}
@@ -148,6 +148,7 @@ class ServersController extends Controller
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
if ($with_resources) {
$server['resources'] = $server->definedResources()->map(function ($resource) {
$payload = [
@@ -477,6 +478,7 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [ModelsServer::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -701,6 +703,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if ($request->proxy_type) {
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower();
@@ -825,6 +828,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('delete', $server);
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
@@ -924,6 +928,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
ValidateServer::dispatch($server);
auditLog('api.server.validated', [
+199 -28
View File
@@ -39,6 +39,10 @@ class ServicesController extends Controller
]);
}
if ($service->is_shown_once ?? false) {
$service->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($service);
}
@@ -56,19 +60,10 @@ class ServicesController extends Controller
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
$urls = collect(ValidationPatterns::applicationDomainList(
ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
));
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
@@ -97,10 +92,10 @@ class ServicesController extends Controller
}
if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
$containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
$containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
if (isset($result['error'])) {
$errors[] = $result['error'];
@@ -112,8 +107,6 @@ class ServicesController extends Controller
return;
}
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else {
$containerUrls = null;
}
@@ -738,6 +731,125 @@ class ServicesController extends Controller
return response()->json($this->removeSensitiveData($service));
}
#[OA\Get(
summary: 'Get service logs.',
description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.',
path: '/services/{uuid}/logs',
operationId: 'get-service-logs-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'sub_service_name',
in: 'query',
description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.',
required: true,
schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get service logs by UUID.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$subServiceName = $request->query->get('sub_service_name');
if (! $subServiceName) {
return response()->json(['message' => 'Sub service name is required.'], 400);
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$name = "{$subServiceName}-{$service->uuid}";
$containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name);
$container = $containers->first();
if (! $container) {
return response()->json(['message' => 'Container not found.'], 404);
}
$status = getContainerStatus($service->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Container is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Delete(
summary: 'Delete',
description: 'Delete service by UUID.',
@@ -1247,8 +1359,12 @@ class ServicesController extends Controller
$this->authorize('manageEnvironment', $service);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -1396,8 +1512,12 @@ class ServicesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -1515,8 +1635,12 @@ class ServicesController extends Controller
$this->authorize('manageEnvironment', $service);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -2099,10 +2223,11 @@ class ServicesController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -2134,7 +2259,7 @@ class ServicesController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2165,6 +2290,14 @@ class ServicesController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -2187,12 +2320,50 @@ class ServicesController extends Controller
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -110,6 +110,7 @@ class TeamController extends Controller
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$this->authorize('view', $team);
$team = $this->removeSensitiveData($team);
return response()->json(
@@ -168,6 +169,7 @@ class TeamController extends Controller
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$this->authorize('view', $team);
$members = $team->members;
$members->makeHidden([
'pivot',
+17 -4
View File
@@ -98,7 +98,7 @@ class Controller extends BaseController
public function link()
{
$token = request()->get('token');
if ($token) {
if (is_string($token) && $token !== '') {
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
@@ -126,9 +126,8 @@ class Controller extends BaseController
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
@@ -139,10 +138,11 @@ class Controller extends BaseController
}
$invitation->delete();
Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
@@ -152,6 +152,19 @@ class Controller extends BaseController
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
{
$query = parse_url($invitation->link, PHP_URL_QUERY);
if (! is_string($query)) {
return false;
}
parse_str($query, $parameters);
$storedToken = $parameters['token'] ?? null;
return is_string($storedToken) && hash_equals($storedToken, $token);
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');
+9 -40
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Controller as BaseController;
@@ -11,26 +13,11 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class UploadController extends BaseController
{
use AuthorizesRequests;
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
private const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
public function upload(Request $request)
{
@@ -40,6 +27,8 @@ class UploadController extends BaseController
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
$this->authorize('uploadBackup', $resource);
$chunk = $request->file('file');
$originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
@@ -80,10 +69,7 @@ class UploadController extends BaseController
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
@unlink($file->getPathname());
return response()->json([
@@ -103,24 +89,7 @@ class UploadController extends BaseController
private static function hasAllowedExtension(string $name): bool
{
$lower = strtolower($name);
$suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem !== '' && ! str_ends_with($stem, '.')) {
return true;
}
return false;
}
return false;
return DatabaseBackupFileValidator::hasAllowedExtension($name);
}
private static function formatMaxSize(): string
+3 -4
View File
@@ -10,7 +10,6 @@ use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
@@ -141,7 +140,7 @@ class Bitbucket extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -163,7 +162,7 @@ class Bitbucket extends Controller
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
'repository' => $full_name ?? null,
]);
@@ -192,7 +191,7 @@ class Bitbucket extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
+3 -4
View File
@@ -11,7 +11,6 @@ use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
@@ -127,7 +126,7 @@ class Gitea extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -149,7 +148,7 @@ class Gitea extends Controller
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
@@ -194,7 +193,7 @@ class Gitea extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
+12 -3
View File
@@ -17,7 +17,6 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Github extends Controller
{
@@ -144,7 +143,7 @@ class Github extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -262,6 +261,16 @@ class Github extends Controller
return response('Nothing to do. No GitHub App found.');
}
$webhook_secret = data_get($github_app, 'webhook_secret');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
@@ -362,7 +371,7 @@ class Github extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
+3 -4
View File
@@ -11,7 +11,6 @@ use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
@@ -168,7 +167,7 @@ class Gitlab extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -191,7 +190,7 @@ class Gitlab extends Controller
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
@@ -236,7 +235,7 @@ class Gitlab extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
+2
View File
@@ -12,6 +12,7 @@ use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTeamMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
@@ -110,5 +111,6 @@ class Kernel extends HttpKernel
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
];
}
+25
View File
@@ -7,9 +7,34 @@ use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
{
/**
* Permissions that only admins/owners may use.
*/
private const MEMBER_DISALLOWED_ABILITIES = [
'root',
'write',
'write:sensitive',
'deploy',
'read:sensitive',
];
public function handle($request, $next, ...$abilities)
{
try {
$token = $request->user()->currentAccessToken();
$teamId = data_get($token, 'team_id');
if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) {
$tokenAbilities = $token->abilities ?? [];
$disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES);
if (! empty($disallowed)) {
return response()->json([
'message' => 'This API token has permissions ('.implode(', ', $disallowed).') that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
], 403);
}
}
if ($request->user()->tokenCan('root')) {
return $next($request);
}
+5 -2
View File
@@ -10,10 +10,13 @@ class ApiSensitiveData
public function handle(Request $request, Closure $next)
{
$token = $request->user()->currentAccessToken();
$hasTokenPermission = $token->can('root') || $token->can('read:sensitive');
$teamId = (int) data_get($token, 'team_id');
$isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false;
// Allow access to sensitive data if token has root or read:sensitive permission
// Allow access to sensitive data only if token has permission AND user is admin/owner
$request->attributes->add([
'can_read_sensitive' => $token->can('root') || $token->can('read:sensitive'),
'can_read_sensitive' => $hasTokenPermission && $isAdmin,
]);
return $next($request);
+5 -6
View File
@@ -12,15 +12,14 @@ class CanCreateResources
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
// if (! Gate::allows('createAnyResource')) {
// abort(403, 'You do not have permission to create resources.');
// }
if (! Gate::allows('createAnyResource')) {
abort(403, 'You do not have permission to create resources.');
}
// return $next($request);
return $next($request);
}
}
+49 -40
View File
@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -23,53 +24,61 @@ use Symfony\Component\HttpFoundation\Response;
class CanUpdateResource
{
/**
* @var array<string, list<class-string>>
*/
private const ROUTE_RESOURCE_MODELS = [
'application_uuid' => [Application::class],
'database_uuid' => [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
StandaloneMongodb::class,
],
'stack_service_uuid' => [ServiceApplication::class, ServiceDatabase::class],
'service_uuid' => [Service::class],
'server_uuid' => [Server::class],
'environment_uuid' => [Environment::class],
'project_uuid' => [Project::class],
];
public function handle(Request $request, Closure $next): Response
{
$resource = $this->resourceFromRoute($request);
if (! $resource) {
abort(404, 'Resource not found.');
}
if (! Gate::allows('update', $resource)) {
abort(403, 'You do not have permission to update this resource.');
}
return $next($request);
}
// Get resource from route parameters
// $resource = null;
// if ($request->route('application_uuid')) {
// $resource = Application::where('uuid', $request->route('application_uuid'))->first();
// } elseif ($request->route('service_uuid')) {
// $resource = Service::where('uuid', $request->route('service_uuid'))->first();
// } elseif ($request->route('stack_service_uuid')) {
// // Handle ServiceApplication or ServiceDatabase
// $stack_service_uuid = $request->route('stack_service_uuid');
// $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ??
// ServiceDatabase::where('uuid', $stack_service_uuid)->first();
// } elseif ($request->route('database_uuid')) {
// // Try different database types
// $database_uuid = $request->route('database_uuid');
// $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ??
// StandaloneMysql::where('uuid', $database_uuid)->first() ??
// StandaloneMariadb::where('uuid', $database_uuid)->first() ??
// StandaloneRedis::where('uuid', $database_uuid)->first() ??
// StandaloneKeydb::where('uuid', $database_uuid)->first() ??
// StandaloneDragonfly::where('uuid', $database_uuid)->first() ??
// StandaloneClickhouse::where('uuid', $database_uuid)->first() ??
// StandaloneMongodb::where('uuid', $database_uuid)->first();
// } elseif ($request->route('server_uuid')) {
// // For server routes, check if user can manage servers
// if (! auth()->user()->isAdmin()) {
// abort(403, 'You do not have permission to access this resource.');
// }
private function resourceFromRoute(Request $request): ?object
{
foreach (self::ROUTE_RESOURCE_MODELS as $routeParameter => $models) {
$uuid = $request->route($routeParameter);
// return $next($request);
// } elseif ($request->route('environment_uuid')) {
// $resource = Environment::where('uuid', $request->route('environment_uuid'))->first();
// } elseif ($request->route('project_uuid')) {
// $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first();
// }
if (! $uuid) {
continue;
}
// if (! $resource) {
// abort(404, 'Resource not found.');
// }
foreach ($models as $model) {
$resource = $model::where('uuid', $uuid)->first();
// if (! Gate::allows('update', $resource)) {
// abort(403, 'You do not have permission to update this resource.');
// }
if ($resource) {
return $resource;
}
}
}
// return $next($request);
return null;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTeamMcpEnabled
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $user?->currentAccessToken()?->team_id;
$team = $user?->teams()
->where('teams.id', $teamId)
->first();
if (! $team?->is_mcp_server_enabled) {
return response()->json(['message' => 'MCP server is disabled for this team.'], 403);
}
return $next($request);
}
}
+148 -24
View File
@@ -37,7 +37,6 @@ use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use Visus\Cuid2\Cuid2;
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -53,6 +52,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
'BUILDX_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_BUILDKIT',
'DOCKER_CERT_PATH',
'DOCKER_CLI_EXPERIMENTAL',
'DOCKER_CONFIG',
'DOCKER_CONTEXT',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
];
public $tries = 1;
public $timeout = 3600;
@@ -1032,7 +1046,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
}
foreach ($this->application->fileStorages as $fileStorage) {
if (! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
$fileStorage->saveStorageOnServer();
}
}
@@ -1702,6 +1716,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1753,6 +1771,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1834,8 +1856,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
]
);
}
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
@@ -2125,7 +2147,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
@@ -2207,7 +2229,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
queue_application_deployment(
deployment_uuid: $deployment_uuid,
application: $this->application,
@@ -2230,7 +2252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
$this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
}
if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn;
@@ -2242,17 +2264,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} ";
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} ";
$this->coolify_variables .= "COOLIFY_FQDN={$url} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
}
if (isset($this->application->git_branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
$this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
}
private function shellAssignmentForDockerfileArg(string $assignment): string
{
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
if ($value === null) {
return $assignment;
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
$value = str_replace("'\\''", "'", $value);
}
return "{$key}={$value}";
}
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
@@ -2307,6 +2345,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
[
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
'hidden' => true,
'skip_command_log' => true,
],
[
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
@@ -2326,7 +2366,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
);
}
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
// Note: Git warnings can be on the same line as the result (no newline)
@@ -2358,6 +2398,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function shouldResolveBranchHeadCommit(): bool
{
$commit = trim($this->commit);
return $commit === '' || $commit === 'HEAD';
}
private function clone_repository()
{
$importCommands = $this->generate_git_import_commands();
@@ -2366,12 +2413,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id !== 0) {
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
}
$this->execute_remote_command(
[
$importCommands,
'hidden' => true,
]
);
$this->execute_remote_command(...$this->gitCommandDefinitions($importCommands));
$this->create_workdir();
$this->execute_remote_command(
[
@@ -2401,6 +2443,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $commands;
}
private function gitCommandDefinitions(Collection|array|string $commands): array
{
if (is_string($commands)) {
return [
[
$commands,
'hidden' => true,
],
];
}
return collect($commands)
->map(function ($command): array {
if (is_string($command)) {
return [
$command,
'hidden' => true,
];
}
if (is_array($command)) {
return $command + ['hidden' => true];
}
return [
'command' => $command,
'hidden' => true,
];
})
->values()
->all();
}
private function cleanup_git()
{
$this->execute_remote_command(
@@ -2537,6 +2612,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
private function is_reserved_docker_client_env_key(?string $key): bool
{
if (blank($key)) {
return false;
}
return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true);
}
private function without_reserved_docker_client_variables(Collection $variables): Collection
{
return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key));
}
private function generate_railpack_env_variables(): Collection
{
$variables = $this->railpack_build_variables();
@@ -2657,6 +2746,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function railpack_build_command(string $imageName, Collection $variables): string
{
$variables = $this->without_reserved_docker_client_variables($variables);
$cacheArgs = '';
if ($this->force_rebuild) {
$cacheArgs = '--no-cache';
@@ -2668,12 +2759,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
}
$environmentPrefix = $this->railpack_build_environment_prefix($variables);
// Build-time variables reach the build through the sourced build-time .env file
// (written by save_buildtime_environment_variables), which interpolates shell-style
// references such as BETTER_AUTH_URL=$COOLIFY_URL. Passing them inline via `env`
// would forward the literal `$COOLIFY_URL` because each value is single-quoted and
// `env` does not interpolate its own assignments. Only buildpack control variables
// (NIXPACKS_/RAILPACK_) — which are excluded from the build-time .env file and never
// need interpolation — are still passed inline.
$controlVariables = $variables->filter(
fn ($value, $key) => str($key)->startsWith(EnvironmentVariable::BUILDPACK_CONTROL_VARIABLE_PREFIXES)
);
$environmentPrefix = $this->railpack_build_environment_prefix($controlVariables);
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
$buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
@@ -2683,6 +2784,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
.' --load'
." -t {$imageName}"
." {$this->workdir}";
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
}
private function decode_railpack_config(string $config, string $source): array
@@ -2836,9 +2940,25 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
}
private function ensure_helper_docker_buildx_available_for_railpack(): void
{
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
'hidden' => true,
'save' => 'railpack_helper_buildx_available',
]);
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
return;
}
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
}
private function build_railpack_image(): void
{
$this->ensure_docker_buildx_available_for_railpack();
$this->ensure_helper_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file();
@@ -4038,6 +4158,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$variables = $this->env_args;
if ($this->build_pack === 'railpack') {
$variables = $this->without_reserved_docker_client_variables($variables);
}
if ($variables->isEmpty()) {
return '';
}
@@ -4178,7 +4302,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
@@ -4200,7 +4324,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
+1 -1
View File
@@ -36,7 +36,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("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
+11 -5
View File
@@ -16,6 +16,7 @@ use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -27,7 +28,6 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -309,7 +309,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
$this->backup_log_uuid = (string) new Cuid2;
$this->backup_log_uuid = new_public_id();
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
@@ -715,9 +715,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$escapedBackupLocation = escapeshellarg($this->backup_location);
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
@@ -733,7 +739,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function getFullImageName(): string
{
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
+18 -9
View File
@@ -14,7 +14,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Visus\Cuid2\Cuid2;
use Throwable;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
@@ -71,16 +71,25 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
->first();
if ($found) {
ApplicationPullRequestUpdateJob::dispatchSync(
application: $application,
preview: $found,
status: ProcessStatus::CLOSED
);
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found);
try {
$this->dispatchPullRequestClosedUpdate($application, $found);
} catch (Throwable $e) {
report($e);
} finally {
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found);
}
}
}
protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void
{
ApplicationPullRequestUpdateJob::dispatchSync(
application: $application,
preview: $preview,
status: ProcessStatus::CLOSED
);
}
private function handleOpenAction(Application $application, ?GithubApp $githubApp): void
{
if (! $application->isPRDeployable()) {
@@ -156,7 +165,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
}
// Queue the deployment
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
queue_application_deployment(
application: $application,
pull_request_id: $this->pullRequestId,
+29 -1
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Notifications\Dto\DiscordMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -41,6 +44,31 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
Http::post($this->webhookUrl, $this->message->toPayload());
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
}
}
+40 -6
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Notifications\Dto\SlackMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -34,8 +37,33 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
if ($this->isSlackWebhook()) {
$this->sendToSlack();
$this->sendToSlack($httpOptions);
return;
}
@@ -45,7 +73,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
*
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/
$this->sendToMattermost();
$this->sendToMattermost($httpOptions);
}
private function isSlackWebhook(): bool
@@ -62,9 +90,12 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
return $scheme === 'https' && $host === 'hooks.slack.com';
}
private function sendToSlack(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToSlack(array $httpOptions): void
{
Http::post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'text' => $this->message->title,
'blocks' => [
[
@@ -102,11 +133,14 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
/**
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
*/
private function sendToMattermost(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToMattermost(array $httpOptions): void
{
$username = config('app.name');
Http::post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'username' => $username,
'attachments' => [
[
+10 -14
View File
@@ -50,28 +50,24 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
if ($validator->fails()) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
if (isDev()) {
ray('Sending webhook notification', [
'url' => $this->webhookUrl,
'payload' => $this->payload,
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
$response = Http::post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
'status' => $response->status(),
'body' => $response->body(),
'successful' => $response->successful(),
]);
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
}
}
-1
View File
@@ -179,7 +179,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
$this->server->update(['hetzner_server_status' => $status]);
$this->server->hetzner_server_status = $status;
if ($status === 'off') {
ray('Server is powered off, marking as unreachable');
throw new \Exception('Server is powered off');
}
}
+7 -4
View File
@@ -36,7 +36,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$data = data_get($this->event, 'data.object');
switch ($type) {
case 'radar.early_fraud_warning.created':
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$id = data_get($data, 'id');
$charge = data_get($data, 'charge');
if ($charge) {
@@ -100,7 +100,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
if ($subscription->stripe_subscription_id) {
try {
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
@@ -166,7 +166,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
@@ -260,7 +260,10 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$quantity = max(
UpdateSubscriptionQuantity::MIN_SERVER_LIMIT,
min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT)
);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
+2 -1
View File
@@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -27,7 +28,7 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
$subscription = $this->team->subscription;
if ($subscription && $subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
if ($subscription->stripe_subscription_id) {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
+4 -3
View File
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -33,7 +34,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
->where('stripe_invoice_paid', true)
->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
@@ -123,7 +124,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
*
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
*/
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array
{
try {
$customer = $stripe->customers->retrieve($customerId);
@@ -177,7 +178,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
*
* @return array<string>
*/
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
{
$validIds = [];
$fetched = 0;
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Stripe\StripeClient;
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -29,7 +30,7 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu
if (! $this->subscription->stripe_subscription_id &&
$this->subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$subscriptions = $stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id,
'limit' => 1,
@@ -50,7 +51,7 @@ class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueu
}
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripe = app(StripeClient::class);
$stripeSubscription = $stripe->subscriptions->retrieve(
$this->subscription->stripe_subscription_id
);
+3
View File
@@ -54,6 +54,9 @@ class Index extends Component
public function getSubscribers()
{
if (Auth::id() !== 0 && ! session('impersonating')) {
return redirect()->route('dashboard');
}
$this->inactiveSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', false)->count();
$this->activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->count();
}
+14 -2
View File
@@ -9,13 +9,15 @@ use App\Models\Server;
use App\Models\Team;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Index extends Component
{
use AuthorizesRequests;
protected $listeners = [
'refreshBoardingIndex' => 'validateServer',
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
@@ -174,6 +176,9 @@ class Index extends Component
public function skipBoarding()
{
if (auth()->user()?->isMember()) {
return redirect()->route('dashboard');
}
Team::find(currentTeam()->id)->update([
'show_boarding' => false,
]);
@@ -276,6 +281,7 @@ class Index extends Component
]);
try {
$this->authorize('create', PrivateKey::class);
$privateKey = PrivateKey::createAndStore([
'name' => $this->privateKeyName,
'description' => $this->privateKeyDescription,
@@ -294,6 +300,12 @@ class Index extends Component
{
$this->validate();
try {
$this->authorize('create', Server::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
if ($foundServer) {
@@ -457,7 +469,7 @@ class Index extends Component
$this->createdProject = Project::create([
'name' => 'My first project',
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
$this->currentState = 'create-resource';
}
+2 -3
View File
@@ -9,7 +9,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Docker extends Component
{
@@ -35,7 +34,7 @@ class Docker extends Component
public function mount(?string $server_id = null): void
{
$this->network = (string) new Cuid2;
$this->network = new_public_id();
$this->servers = Server::isUsable()->get();
if (filled($server_id)) {
@@ -68,7 +67,7 @@ class Docker extends Component
public function generateName(): void
{
$name = data_get($this->selectedServer, 'name', new Cuid2);
$name = data_get($this->selectedServer, 'name', new_public_id());
$this->name = str("{$name}-{$this->network}")->kebab();
}
+5
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Destination;
use App\Models\StandaloneDocker;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -31,8 +32,12 @@ class Show extends Component
if (! $destination) {
return redirect()->route('destination.index');
}
$this->authorize('view', $destination);
$this->destination = $destination;
$this->syncData();
} catch (AuthorizationException) {
abort(403);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+8 -2
View File
@@ -251,7 +251,6 @@ class GlobalSearch extends Component
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
ray()->showQueries();
$items = collect();
$team = auth()->user()->currentTeam();
@@ -530,7 +529,6 @@ class GlobalSearch extends Component
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
];
});
ray($servers);
// Get all projects
$projects = Project::ownedByCurrentTeam()
->withCount(['environments', 'applications', 'services'])
@@ -1053,6 +1051,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new postgresql)',
'type' => 'postgresql',
'category' => 'Databases',
'logo' => 'svgs/postgresql.svg',
'resourceType' => 'database',
]);
@@ -1062,6 +1061,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new mysql)',
'type' => 'mysql',
'category' => 'Databases',
'logo' => 'svgs/mysql.svg',
'resourceType' => 'database',
]);
@@ -1071,6 +1071,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new mariadb)',
'type' => 'mariadb',
'category' => 'Databases',
'logo' => 'svgs/mariadb.svg',
'resourceType' => 'database',
]);
@@ -1080,6 +1081,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new redis)',
'type' => 'redis',
'category' => 'Databases',
'logo' => 'svgs/redis.svg',
'resourceType' => 'database',
]);
@@ -1089,6 +1091,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new keydb)',
'type' => 'keydb',
'category' => 'Databases',
'logo' => 'svgs/keydb.svg',
'resourceType' => 'database',
]);
@@ -1098,6 +1101,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new dragonfly)',
'type' => 'dragonfly',
'category' => 'Databases',
'logo' => 'svgs/dragonfly.svg',
'resourceType' => 'database',
]);
@@ -1107,6 +1111,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new mongodb)',
'type' => 'mongodb',
'category' => 'Databases',
'logo' => 'svgs/mongodb.svg',
'resourceType' => 'database',
]);
@@ -1116,6 +1121,7 @@ class GlobalSearch extends Component
'quickcommand' => '(type: new clickhouse)',
'type' => 'clickhouse',
'category' => 'Databases',
'logo' => 'svgs/clickhouse-icon.svg',
'resourceType' => 'database',
]);
}
+1 -2
View File
@@ -4,7 +4,6 @@ namespace App\Livewire;
// use Livewire\Component;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class MonacoEditor extends Component
{
@@ -40,7 +39,7 @@ class MonacoEditor extends Component
public function render()
{
if (is_null($this->id)) {
$this->id = new Cuid2;
$this->id = new_public_id();
}
if (is_null($this->name)) {
+32 -20
View File
@@ -2,12 +2,16 @@
namespace App\Livewire;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class NavbarDeleteTeam extends Component
{
use AuthorizesRequests;
public $team;
public function mount()
@@ -17,27 +21,35 @@ class NavbarDeleteTeam extends Component
public function delete($password, $selectedActions = [])
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
try {
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$currentTeam = currentTeam();
$this->authorize('delete', $currentTeam);
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
$newTeam = Auth::user()->teams()->first();
refreshSession($newTeam);
return redirect()->route('team.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
$currentTeam = currentTeam();
$currentTeam->delete();
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
refreshSession();
return redirectRoute($this, 'team.index');
}
public function render()
+3 -1
View File
@@ -110,7 +110,9 @@ class Discord extends Component
refreshSession();
} else {
$this->discordEnabled = $this->settings->discord_enabled;
$this->discordWebhookUrl = $this->settings->discord_webhook_url;
$this->discordWebhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->discord_webhook_url
: null;
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
+10 -2
View File
@@ -170,11 +170,15 @@ class Email extends Component
$this->smtpPort = $this->settings->smtp_port;
$this->smtpEncryption = $this->settings->smtp_encryption;
$this->smtpUsername = $this->settings->smtp_username;
$this->smtpPassword = $this->settings->smtp_password;
$this->smtpPassword = auth()->user()->can('update', $this->settings)
? $this->settings->smtp_password
: null;
$this->smtpTimeout = $this->settings->smtp_timeout;
$this->resendEnabled = $this->settings->resend_enabled;
$this->resendApiKey = $this->settings->resend_api_key;
$this->resendApiKey = auth()->user()->can('update', $this->settings)
? $this->settings->resend_api_key
: null;
$this->useInstanceEmailSettings = $this->settings->use_instance_email_settings;
@@ -242,6 +246,8 @@ class Email extends Component
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
@@ -289,6 +295,8 @@ class Email extends Component
public function submitResend()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
+7 -2
View File
@@ -113,8 +113,13 @@ class Pushover extends Component
refreshSession();
} else {
$this->pushoverEnabled = $this->settings->pushover_enabled;
$this->pushoverUserKey = $this->settings->pushover_user_key;
$this->pushoverApiToken = $this->settings->pushover_api_token;
if (auth()->user()->can('update', $this->settings)) {
$this->pushoverUserKey = $this->settings->pushover_user_key;
$this->pushoverApiToken = $this->settings->pushover_api_token;
} else {
$this->pushoverUserKey = null;
$this->pushoverApiToken = null;
}
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
+3 -1
View File
@@ -110,7 +110,9 @@ class Slack extends Component
refreshSession();
} else {
$this->slackEnabled = $this->settings->slack_enabled;
$this->slackWebhookUrl = $this->settings->slack_webhook_url;
$this->slackWebhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->slack_webhook_url
: null;
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
+7 -2
View File
@@ -169,8 +169,13 @@ class Telegram extends Component
$this->settings->save();
} else {
$this->telegramEnabled = $this->settings->telegram_enabled;
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
if (auth()->user()->can('update', $this->settings)) {
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
} else {
$this->telegramToken = null;
$this->telegramChatId = null;
}
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
+3 -15
View File
@@ -105,7 +105,9 @@ class Webhook extends Component
refreshSession();
} else {
$this->webhookEnabled = $this->settings->webhook_enabled;
$this->webhookUrl = $this->settings->webhook_url;
$this->webhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->webhook_url
: null;
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
@@ -166,13 +168,6 @@ class Webhook extends Component
$this->syncData(true);
refreshSession();
if (isDev()) {
ray('Webhook settings saved', [
'webhook_enabled' => $this->settings->webhook_enabled,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->dispatch('success', 'Settings saved.');
}
@@ -181,13 +176,6 @@ class Webhook extends Component
try {
$this->authorize('sendTest', $this->settings);
if (isDev()) {
ray('Sending test webhook notification', [
'team_id' => $this->team->id,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->team->notify(new Test(channel: 'webhook'));
$this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) {
+5 -2
View File
@@ -4,11 +4,13 @@ namespace App\Livewire\Project;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class AddEmpty extends Component
{
use AuthorizesRequests;
public string $name;
public string $description = '';
@@ -29,12 +31,13 @@ class AddEmpty extends Component
public function submit()
{
try {
$this->authorize('create', Project::class);
$this->validate();
$project = Project::create([
'name' => $this->name,
'description' => $this->description,
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
$productionEnvironment = $project->environments()->where('name', 'production')->first();
@@ -59,7 +59,9 @@ class Show extends Component
$this->application_deployment_queue = $application_deployment_queue;
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->deployment_uuid = $deploymentUuid;
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->is_debug_enabled = auth()->user()->isMember()
? false
: $this->application->settings->is_debug_enabled;
$this->isKeepAliveOn();
}
@@ -110,6 +112,8 @@ class Show extends Component
public function downloadAllLogs(): string
{
$this->authorize('update', $this->application);
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) {
$prefix = '';
@@ -6,11 +6,14 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Livewire\Component;
class DeploymentNavbar extends Component
{
use AuthorizesRequests;
public ApplicationDeploymentQueue $application_deployment_queue;
public Application $application;
@@ -25,7 +28,9 @@ class DeploymentNavbar extends Component
{
$this->application = Application::ownedByCurrentTeam()->find($this->application_deployment_queue->application_id);
$this->server = $this->application->destination->server;
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->is_debug_enabled = auth()->user()->isMember()
? false
: $this->application->settings->is_debug_enabled;
}
public function deploymentFinished()
@@ -35,15 +40,21 @@ class DeploymentNavbar extends Component
public function show_debug()
{
$this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
$this->application->settings->save();
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->dispatch('refreshQueue');
try {
$this->authorize('update', $this->application);
$this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
$this->application->settings->save();
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->dispatch('refreshQueue');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function force_start()
{
try {
$this->authorize('deploy', $this->application);
force_start_deployment($this->application_deployment_queue);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -58,10 +69,15 @@ class DeploymentNavbar extends Component
return '';
}
$isMember = auth()->user()->isMember();
$markdown = "# Deployment Logs\n\n";
$markdown .= "```\n";
foreach ($logs as $log) {
if ($isMember && ! empty($log['hidden'])) {
continue;
}
if (isset($log['output'])) {
$markdown .= $log['output']."\n";
}
@@ -74,6 +90,11 @@ class DeploymentNavbar extends Component
public function cancel()
{
try {
$this->authorize('deploy', $this->application);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
+7 -14
View File
@@ -12,8 +12,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class General extends Component
{
@@ -143,7 +141,8 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@@ -549,7 +548,7 @@ class General extends Component
try {
$this->authorize('update', $this->application);
$uuid = new Cuid2;
$uuid = new_public_id();
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
@@ -772,16 +771,7 @@ class General extends Component
$oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
@@ -864,6 +854,9 @@ class General extends Component
}
}
if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
+92 -77
View File
@@ -7,7 +7,6 @@ use App\Actions\Docker\GetContainersStatus;
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Heading extends Component
{
@@ -65,107 +64,123 @@ class Heading extends Component
public function force_deploy_without_cache()
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->deploy(force_rebuild: true);
$this->deploy(force_rebuild: true);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deploy(bool $force_rebuild = false)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
return;
return;
}
if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.');
return;
}
if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/build-server">documentation</a>');
return;
}
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.');
return;
}
if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/build-server">documentation</a>');
return;
}
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
}
protected function setDeploymentUuid()
{
$this->deploymentUuid = new Cuid2;
$this->deploymentUuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deploymentUuid;
}
public function stop()
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function restart()
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
}
public function render()
+28 -20
View File
@@ -6,10 +6,10 @@ use App\Actions\Docker\GetContainersStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Previews extends Component
{
@@ -118,12 +118,14 @@ class Previews extends Component
});
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
@@ -234,31 +236,38 @@ class Previews extends Component
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$dockerRegistryImageTag = null;
if ($this->application->build_pack === 'dockerimage') {
$dockerRegistryImageTag = $this->application->previews()
->where('pull_request_id', $pull_request_id)
->value('docker_registry_image_tag');
$dockerRegistryImageTag = null;
if ($this->application->build_pack === 'dockerimage') {
$dockerRegistryImageTag = $this->application->previews()
->where('pull_request_id', $pull_request_id)
->value('docker_registry_image_tag');
}
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
}
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
@@ -305,7 +314,7 @@ class Previews extends Component
protected function setDeploymentUuid()
{
$this->deployment_uuid = new Cuid2;
$this->deployment_uuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}
@@ -350,9 +359,8 @@ class Previews extends Component
public function stop(int $pull_request_id)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$server = $this->application->destination->server;
if ($this->application->destination->server->isSwarm()) {
@@ -3,10 +3,10 @@
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class PreviewsCompose extends Component
{
@@ -34,6 +34,11 @@ class PreviewsCompose extends Component
{
try {
$this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@@ -64,7 +69,7 @@ class PreviewsCompose extends Component
if (empty($domain_string)) {
$server = $this->preview->application->destination->server;
$template = $this->preview->application->preview_url_template;
$random = new Cuid2;
$random = new_public_id();
// Generate a unique domain like main app services do
$generated_fqdn = generateUrl(server: $server, random: $random);
@@ -74,12 +79,16 @@ class PreviewsCompose extends Component
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application
// Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string);
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$random = new Cuid2;
$random = new_public_id();
foreach ($domain_list as $single_domain) {
$single_domain = trim($single_domain);
@@ -6,7 +6,6 @@ use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Rollback extends Component
{
@@ -52,7 +51,7 @@ class Rollback extends Component
$commit = validateGitRef($commit, 'rollback commit');
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $this->application,
@@ -3,11 +3,14 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Swarm extends Component
{
use AuthorizesRequests;
public Application $application;
#[Validate('required')]
@@ -51,6 +54,7 @@ class Swarm extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
@@ -61,6 +65,7 @@ class Swarm extends Component
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
+12 -9
View File
@@ -11,11 +11,13 @@ use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class CloneMe extends Component
{
use AuthorizesRequests;
public string $project_uuid;
public string $environment_uuid;
@@ -61,7 +63,7 @@ class CloneMe extends Component
->servers()
->get()
->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug();
$this->newName = str($this->project->name.'-clone-'.new_public_id())->slug();
}
public function toggleVolumeCloning(bool $value)
@@ -91,6 +93,7 @@ class CloneMe extends Component
public function clone(string $type)
{
try {
$this->authorize('create', Project::class);
$this->validate([
'selectedDestination' => 'required',
'newName' => ValidationPatterns::nameRules(),
@@ -108,7 +111,7 @@ class CloneMe extends Component
if ($this->environment->name !== 'production') {
$project->environments()->create([
'name' => $this->environment->name,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
}
$environment = $project->environments->where('name', $this->environment->name)->first();
@@ -120,7 +123,7 @@ class CloneMe extends Component
$project = $this->project;
$environment = $this->project->environments()->create([
'name' => $this->newName,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
}
$applications = $this->environment->applications;
@@ -134,7 +137,7 @@ class CloneMe extends Component
}
foreach ($databases as $database) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newDatabase = $database->replicate([
'id',
'created_at',
@@ -225,7 +228,7 @@ class CloneMe extends Component
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
@@ -254,7 +257,7 @@ class CloneMe extends Component
}
foreach ($services as $service) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newService = $service->replicate([
'id',
'created_at',
@@ -278,7 +281,7 @@ class CloneMe extends Component
'created_at',
'updated_at',
])->fill([
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
'service_id' => $newService->id,
'team_id' => currentTeam()->id,
]);
@@ -409,7 +412,7 @@ class CloneMe extends Component
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
+52 -6
View File
@@ -2,10 +2,13 @@
namespace App\Livewire\Project\Database;
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -17,7 +20,7 @@ class BackupEdit extends Component
public ScheduledDatabaseBackup $backup;
#[Locked]
public $s3s;
public $availableS3Storages;
#[Locked]
public $parameters;
@@ -68,7 +71,7 @@ class BackupEdit extends Component
public bool $disableLocalBackup = false;
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = 1;
public ?int $s3StorageId = null;
#[Validate(['nullable', 'string'])]
public ?string $databasesToBackup = null;
@@ -128,7 +131,7 @@ class BackupEdit extends Component
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
$this->saveS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
$this->s3StorageId = $this->backup->s3_storage_id;
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first();
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
@@ -190,6 +193,18 @@ class BackupEdit extends Component
}
}
public function backupNow()
{
try {
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
@@ -202,6 +217,11 @@ class BackupEdit extends Component
}
}
public function updatedS3StorageId(): void
{
$this->instantSave();
}
private function customValidate()
{
if (! is_numeric($this->backup->s3_storage_id)) {
@@ -209,10 +229,14 @@ class BackupEdit extends Component
}
// S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = collect($this->s3s)->pluck('id');
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->save_s3 = $this->saveS3 = false;
$availableS3Ids = $this->availableS3StorageIds();
if ($availableS3Ids->isEmpty()) {
$this->backup->s3_storage_id = $this->s3StorageId = null;
if ($this->backup->save_s3) {
$this->backup->save_s3 = $this->saveS3 = false;
}
} elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
}
// Validate that disable_local_backup can only be true when S3 backup is enabled
@@ -227,6 +251,28 @@ class BackupEdit extends Component
$this->validate();
}
private function availableS3StorageIds(): Collection
{
$storages = collect($this->availableS3Storages);
$storageIds = $storages->pluck('id')->filter()->all();
if (empty($storageIds)) {
return collect();
}
$teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all();
if (empty($teamIds)) {
return collect();
}
return S3Storage::query()
->whereKey($storageIds)
->whereIn('team_id', $teamIds)
->where('is_usable', true)
->pluck('id');
}
public function submit()
{
try {
@@ -3,12 +3,16 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class BackupExecutions extends Component
{
use AuthorizesRequests;
public ?ScheduledDatabaseBackup $backup = null;
public $database;
@@ -44,29 +48,45 @@ class BackupExecutions extends Component
public function cleanupFailed()
{
if ($this->backup) {
$this->backup->executions()->where('status', 'failed')->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', 'Failed backups cleaned up.');
try {
$this->authorize('manageBackups', $this->database);
if ($this->backup) {
$this->backup->executions()->where('status', 'failed')->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', 'Failed backups cleaned up.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function cleanupDeleted()
{
if ($this->backup) {
$deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
if ($deletedCount > 0) {
$this->backup->executions()->where('local_storage_deleted', true)->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
} else {
$this->dispatch('info', 'No backup entries found that are deleted from local storage.');
try {
$this->authorize('manageBackups', $this->database);
if ($this->backup) {
$deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
if ($deletedCount > 0) {
$this->backup->executions()->where('local_storage_deleted', true)->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
} else {
$this->dispatch('info', 'No backup entries found that are deleted from local storage.');
}
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteBackup($executionId, $password, $selectedActions = [])
{
try {
$this->authorize('manageBackups', $this->database);
} catch (\Throwable $e) {
return handleError($e, $this);
}
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
@@ -78,7 +98,7 @@ class BackupExecutions extends Component
return;
}
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class
? $execution->scheduledDatabaseBackup->database->service->destination->server
: $execution->scheduledDatabaseBackup->database->destination->server;
@@ -185,7 +205,7 @@ class BackupExecutions extends Component
if ($this->database) {
$server = null;
if ($this->database instanceof \App\Models\ServiceDatabase) {
if ($this->database instanceof ServiceDatabase) {
$server = $this->database->service->destination->server;
} elseif ($this->database->destination && $this->database->destination->server) {
$server = $this->database->destination->server;
+7 -3
View File
@@ -14,9 +14,13 @@ class BackupNow extends Component
public function backupNow()
{
$this->authorize('manageBackups', $this->backup->database);
try {
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
@@ -42,6 +42,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function getListeners(): array
{
$user = Auth::user();
@@ -72,6 +74,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->clickhouseAdminPassword = '';
}
}
protected function rules(): array
@@ -40,6 +40,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function getListeners(): array
{
$user = Auth::user();
@@ -70,6 +72,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->dragonflyPassword = '';
}
}
protected function rules(): array
+16 -6
View File
@@ -90,18 +90,28 @@ class Heading extends Component
public function restart()
{
$this->authorize('manage', $this->database);
try {
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$activity = RestartDatabase::run($this->database);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function start()
{
$this->authorize('manage', $this->database);
try {
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$activity = StartDatabase::run($this->database);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
+82 -5
View File
@@ -14,6 +14,8 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@@ -27,11 +29,10 @@ class ImportForm extends Component
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
return ValidationPatterns::isValidS3BucketName($bucket);
}
/**
@@ -451,10 +452,20 @@ EOD;
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
Storage::delete($backupFileName);
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
return true;
}
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
@@ -465,6 +476,7 @@ EOD;
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@@ -570,7 +582,7 @@ EOD;
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return;
}
@@ -587,6 +599,7 @@ EOD;
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
]);
// Check if file exists
@@ -651,7 +664,7 @@ EOD;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return true;
}
@@ -667,7 +680,7 @@ EOD;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
@@ -721,6 +734,7 @@ EOD;
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
@@ -765,6 +779,69 @@ EOD;
return true;
}
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
{
$script = $this->buildPostgresRestoreScanScript($tmpPath);
if ($script === null) {
return null;
}
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
}
/**
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
* backup contains directives leading to OS command execution.
*
* Hardened against bypasses:
* - decompresses gzip backups before scanning,
* - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
*/
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
{
if (! $this->isPostgresqlRestore()) {
return null;
}
$escapedTmpPath = escapeshellarg($tmpPath);
// Token separator PostgreSQL treats as whitespace: real whitespace or a
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$pattern = implode('|', [
"copy{$sep}+[^;]*(from|to){$sep}+program",
'(^|[[:space:]])\\\\!',
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
]);
$escapedPattern = escapeshellarg($pattern);
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
{
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
if ($command !== null) {
$commands[] = $command;
}
}
private function isPostgresqlRestore(): bool
{
$morphClass = $this->resource->getMorphClass();
if ($morphClass === ServiceDatabase::class) {
return str_contains($this->resource->databaseType(), 'postgres');
}
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
@@ -2,13 +2,20 @@
namespace App\Livewire\Project\Database;
use App\Models\StandalonePostgresql;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
class InitScript extends Component
{
use AuthorizesRequests;
#[Locked]
public StandalonePostgresql $database;
#[Locked]
public array $script;
@@ -35,6 +42,7 @@ class InitScript extends Component
public function submit()
{
try {
$this->authorize('update', $this->database);
$this->validate();
$this->script['index'] = $this->index;
$this->script['content'] = $this->content;
@@ -48,6 +56,7 @@ class InitScript extends Component
public function delete()
{
try {
$this->authorize('update', $this->database);
$this->dispatch('delete_init_script', $this->script);
} catch (Exception $e) {
return handleError($e, $this);
@@ -42,6 +42,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function getListeners(): array
{
$user = Auth::user();
@@ -72,6 +74,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->keydbPassword = '';
}
}
protected function rules(): array
@@ -47,6 +47,8 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $isPasswordHiddenForMember = false;
protected function rules(): array
{
return [
@@ -126,6 +128,12 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->mariadbRootPassword = '';
$this->mariadbPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -45,6 +45,8 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $isPasswordHiddenForMember = false;
protected function rules(): array
{
return [
@@ -119,6 +121,11 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->mongoInitdbRootPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -47,6 +47,8 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $isPasswordHiddenForMember = false;
protected function rules(): array
{
return [
@@ -126,6 +128,12 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->mysqlRootPassword = '';
$this->mysqlPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -55,6 +55,8 @@ class General extends Component
public string $new_content;
public bool $isPasswordHiddenForMember = false;
protected $listeners = [
'save_init_script',
'delete_init_script',
@@ -140,6 +142,11 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->postgresPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -45,6 +45,8 @@ class General extends Component
public string $redisVersion;
public bool $isPasswordHiddenForMember = false;
protected $listeners = [
'envsUpdated' => 'refresh',
];
@@ -118,6 +120,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->redisPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -34,7 +35,7 @@ class ScheduledBackups extends Component
$this->setSelectedBackup($this->selectedBackupId, true);
}
$this->parameters = get_route_parameters();
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->type = 'service-database';
} else {
$this->type = 'database';
@@ -56,22 +57,30 @@ class ScheduledBackups extends Component
public function setCustomType()
{
$this->authorize('update', $this->database);
try {
$this->authorize('update', $this->database);
$this->database->custom_type = $this->custom_type;
$this->database->save();
$this->dispatch('success', 'Database type set.');
$this->refreshScheduledBackups();
$this->database->custom_type = $this->custom_type;
$this->database->save();
$this->dispatch('success', 'Database type set.');
$this->refreshScheduledBackups();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function delete($scheduled_backup_id): void
{
$backup = $this->database->scheduledBackups->find($scheduled_backup_id);
$this->authorize('manageBackups', $this->database);
try {
$this->authorize('manageBackups', $this->database);
$backup->delete();
$this->dispatch('success', 'Scheduled backup deleted.');
$this->refreshScheduledBackups();
$backup = $this->database->scheduledBackups->find($scheduled_backup_id);
$backup->delete();
$this->dispatch('success', 'Scheduled backup deleted.');
$this->refreshScheduledBackups();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function refreshScheduledBackups(?int $id = null): void
+14 -10
View File
@@ -28,18 +28,22 @@ class DeleteEnvironment extends Component
public function delete()
{
$this->validate([
'environment_id' => 'required|int',
]);
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
try {
$this->validate([
'environment_id' => 'required|int',
]);
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
if ($environment->isEmpty()) {
$environment->delete();
if ($environment->isEmpty()) {
$environment->delete();
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
}
}
+14 -10
View File
@@ -26,18 +26,22 @@ class DeleteProject extends Component
public function delete()
{
$this->validate([
'project_id' => 'required|int',
]);
$project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
$this->authorize('delete', $project);
try {
$this->validate([
'project_id' => 'required|int',
]);
$project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
$this->authorize('delete', $project);
if ($project->isEmpty()) {
$project->delete();
if ($project->isEmpty()) {
$project->delete();
return redirectRoute($this, 'project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
}
}
+4
View File
@@ -4,10 +4,13 @@ namespace App\Livewire\Project;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Edit extends Component
{
use AuthorizesRequests;
public Project $project;
public string $name;
@@ -54,6 +57,7 @@ class Edit extends Component
public function submit()
{
try {
$this->authorize('update', $this->project);
$this->syncData(true);
$this->dispatch('success', 'Project updated.');
} catch (\Throwable $e) {
+4
View File
@@ -5,11 +5,14 @@ namespace App\Livewire\Project;
use App\Models\Application;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class EnvironmentEdit extends Component
{
use AuthorizesRequests;
public Project $project;
public Application $application;
@@ -62,6 +65,7 @@ class EnvironmentEdit extends Component
public function submit()
{
try {
$this->authorize('update', $this->environment);
$this->syncData(true);
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
@@ -5,11 +5,14 @@ namespace App\Livewire\Project\New;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
class DockerCompose extends Component
{
use AuthorizesRequests;
public string $dockerComposeRaw = '';
public string $envFile = '';
@@ -30,6 +33,8 @@ class DockerCompose extends Component
public function submit()
{
try {
$this->authorize('create', Service::class);
$this->validate([
'dockerComposeRaw' => 'required',
]);
+6 -2
View File
@@ -6,11 +6,13 @@ use App\Models\Application;
use App\Models\Project;
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class DockerImage extends Component
{
use AuthorizesRequests;
public string $imageName = '';
public string $imageTag = '';
@@ -81,6 +83,8 @@ class DockerImage extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ValidationPatterns::dockerImageTagRules(),
@@ -130,7 +134,7 @@ class DockerImage extends Component
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'name' => 'docker-image-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
+6 -2
View File
@@ -3,17 +3,21 @@
namespace App\Livewire\Project\New;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class EmptyProject extends Component
{
use AuthorizesRequests;
public function createEmptyProject()
{
$this->authorize('create', Project::class);
$project = Project::create([
'name' => generate_random_name(),
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
@@ -14,6 +15,8 @@ use Livewire\Component;
class GithubPrivateRepository extends Component
{
use AuthorizesRequests;
public $current_step = 'github_apps';
public $github_apps;
@@ -169,6 +172,8 @@ class GithubPrivateRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
// Validate git repository parts and branch
$validator = validator([
'selected_repository_owner' => $this->selected_repository_owner,
@@ -10,12 +10,15 @@ use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Livewire\Component;
use Spatie\Url\Url;
class GithubPrivateRepositoryDeployKey extends Component
{
use AuthorizesRequests;
public $current_step = 'private_keys';
public $parameters;
@@ -128,6 +131,8 @@ class GithubPrivateRepositoryDeployKey extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate();
try {
$destination_uuid = $this->query['destination'] ?? null;
@@ -6,16 +6,18 @@ use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
class PublicGitRepository extends Component
{
use AuthorizesRequests;
public string $repository_url;
public int $port = 3000;
@@ -260,6 +262,8 @@ class PublicGitRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
$this->validate();
// Additional validation for git repository and branch
@@ -295,33 +299,6 @@ class PublicGitRepository extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
$server = $destination->server;
$new_service = [
'name' => 'service'.str()->random(10),
'docker_compose_raw' => 'coolify',
'environment_id' => $environment->id,
'server_id' => $server->id,
];
if ($this->git_source === 'other') {
$new_service['git_repository'] = $this->git_repository;
$new_service['git_branch'] = $this->git_branch;
} else {
$new_service['git_repository'] = $this->git_repository;
$new_service['git_branch'] = $this->git_branch;
$new_service['source_id'] = $this->git_source->id;
$new_service['source_type'] = $this->git_source->getMorphClass();
}
$service = Service::create($new_service);
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,
]);
return;
}
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),
+33 -16
View File
@@ -6,7 +6,6 @@ use App\Models\Project;
use App\Models\Server;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class Select extends Component
@@ -107,20 +106,23 @@ class Select extends Component
public function loadServices()
{
$services = get_service_templates();
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys());
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services);
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
$default_logo = 'images/default.webp';
$logo = data_get($service, 'logo', $default_logo);
$local_logo_path = public_path($logo);
$serviceKey = (string) $key;
return [
'name' => str($key)->headline(),
'id' => $serviceKey,
'name' => str($serviceKey)->headline(),
'docsSlug' => str($serviceKey)->lower()->value(),
'logo' => asset($logo),
'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null,
'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
] + (array) $service;
})->all();
@@ -279,19 +281,31 @@ class Select extends Component
return $this->formatLastModified($this->serviceTemplatesPath());
}
private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array
private function serviceTemplateLastUpdatedMap(Collection $services): array
{
$bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0;
return $services
->mapWithKeys(fn ($service, $serviceName) => [
(string) $serviceName => $this->serviceTemplateLastUpdatedFromPayload($service)
?? $this->serviceTemplateLastUpdated((string) $serviceName),
])
->all();
}
return Cache::remember(
"service-template-last-updated-map:{$bundleMtime}",
now()->addDay(),
fn () => $serviceNames
->mapWithKeys(fn ($serviceName) => [
(string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName),
])
->all()
);
private function serviceTemplateLastUpdatedFromPayload(mixed $service): ?string
{
$timestamp = data_get($service, 'template_last_updated_at');
if (! is_string($timestamp) || $timestamp === '') {
return null;
}
try {
return CarbonImmutable::parse($timestamp)
->timezone(config('app.timezone'))
->format('M j, Y H:i');
} catch (\Throwable) {
return null;
}
}
private function serviceTemplateLastUpdated(string $serviceName): ?string
@@ -325,7 +339,10 @@ class Select extends Component
public function setType(string $type)
{
$type = str($type)->lower()->slug()->value();
if (! str($type)->startsWith('one-click-service-')) {
$type = str($type)->lower()->slug()->value();
}
if ($this->loading) {
return;
}

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