diff --git a/README.md b/README.md
index e9ea0e7d4..b7aefe16a 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
+* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
*
### Big Sponsors
@@ -77,6 +78,7 @@ ### Big Sponsors
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
+* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer
diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php
index 021cba13e..b10d783db 100644
--- a/app/Actions/Stripe/RefundSubscription.php
+++ b/app/Actions/Stripe/RefundSubscription.php
@@ -19,7 +19,7 @@ public function __construct(?StripeClient $stripe = null)
/**
* Check if the team's subscription is eligible for a refund.
*
- * @return array{eligible: bool, days_remaining: int, reason: string}
+ * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
public function checkEligibility(Team $team): array
{
@@ -43,8 +43,10 @@ public function checkEligibility(Team $team): array
return $this->ineligible('Subscription not found in Stripe.');
}
+ $currentPeriodEnd = $stripeSubscription->current_period_end;
+
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
- return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.");
+ return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
@@ -52,13 +54,14 @@ public function checkEligibility(Team $team): array
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
if ($daysRemaining <= 0) {
- return $this->ineligible('The 30-day refund window has expired.');
+ return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd);
}
return [
'eligible' => true,
'days_remaining' => $daysRemaining,
'reason' => 'Eligible for refund.',
+ 'current_period_end' => $currentPeriodEnd,
];
}
@@ -99,16 +102,27 @@ public function execute(Team $team): array
'payment_intent' => $paymentIntentId,
]);
- $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
+ // Record refund immediately so it cannot be retried if cancel fails
+ $subscription->update([
+ 'stripe_refunded_at' => now(),
+ 'stripe_feedback' => 'Refund requested by user',
+ 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
+ ]);
+
+ try {
+ $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
+ } catch (\Exception $e) {
+ \Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage());
+ send_internal_notification(
+ "CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required."
+ );
+ }
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
- 'stripe_feedback' => 'Refund requested by user',
- 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
- 'stripe_refunded_at' => now(),
]);
$team->subscriptionEnded();
@@ -128,14 +142,15 @@ public function execute(Team $team): array
}
/**
- * @return array{eligible: bool, days_remaining: int, reason: string}
+ * @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
- private function ineligible(string $reason): array
+ private function ineligible(string $reason, ?int $currentPeriodEnd = null): array
{
return [
'eligible' => false,
'days_remaining' => 0,
'reason' => $reason,
+ 'current_period_end' => $currentPeriodEnd,
];
}
}
diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
index c181e988d..a3eab4dca 100644
--- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php
+++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
@@ -153,12 +153,19 @@ public function execute(Team $team, int $quantity): array
\Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
// Revert subscription quantity on Stripe
- $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
- 'items' => [
- ['id' => $item->id, 'quantity' => $previousQuantity],
- ],
- 'proration_behavior' => 'none',
- ]);
+ try {
+ $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
+ 'items' => [
+ ['id' => $item->id, 'quantity' => $previousQuantity],
+ ],
+ 'proration_behavior' => 'none',
+ ]);
+ } catch (\Exception $revertException) {
+ \Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage());
+ send_internal_notification(
+ "CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required."
+ );
+ }
// Void the unpaid invoice
if ($latestInvoice->id) {
diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php
index 54d5714a6..aa9d06996 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
+use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
@@ -209,12 +210,37 @@ private static function isMultiplexingEnabled(): bool
private static function validateSshKey(PrivateKey $privateKey): void
{
$keyLocation = $privateKey->getKeyLocation();
- $checkKeyCommand = "ls $keyLocation 2>/dev/null";
- $keyCheckProcess = Process::run($checkKeyCommand);
+ $filename = "ssh_key@{$privateKey->uuid}";
+ $disk = Storage::disk('ssh-keys');
- if ($keyCheckProcess->exitCode() !== 0) {
+ $needsRewrite = false;
+
+ if (! $disk->exists($filename)) {
+ $needsRewrite = true;
+ } else {
+ $diskContent = $disk->get($filename);
+ if ($diskContent !== $privateKey->private_key) {
+ Log::warning('SSH key file content does not match database, resyncing', [
+ 'key_uuid' => $privateKey->uuid,
+ ]);
+ $needsRewrite = true;
+ }
+ }
+
+ if ($needsRewrite) {
$privateKey->storeInFileSystem();
}
+
+ // Ensure correct permissions (SSH requires 0600)
+ if (file_exists($keyLocation)) {
+ $currentPerms = fileperms($keyLocation) & 0777;
+ if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
+ Log::warning('Failed to set SSH key file permissions to 0600', [
+ 'key_uuid' => $privateKey->uuid,
+ 'path' => $keyLocation,
+ ]);
+ }
+ }
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 4b0cfc6ab..3444f9f14 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -18,6 +18,7 @@
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
@@ -2471,7 +2472,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application);
$server = $application->destination->server;
- $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_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', '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'];
+ $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_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'];
$validationRules = [
'name' => 'string|max:255',
@@ -2482,8 +2483,6 @@ public function update_by_uuid(Request $request)
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
- 'docker_compose_custom_start_command' => 'string|nullable',
- 'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@@ -2958,7 +2957,7 @@ public function update_env_by_uuid(Request $request)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3159,7 +3158,7 @@ public function create_bulk_envs(Request $request)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3176,7 +3175,7 @@ public function create_bulk_envs(Request $request)
], 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']);
+ return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@@ -3189,6 +3188,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
@@ -3221,6 +3221,9 @@ public function create_bulk_envs(Request $request)
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
+ if ($item->has('comment') && $env->comment != $item->get('comment')) {
+ $env->comment = $item->get('comment');
+ }
$env->save();
} else {
$env = $application->environment_variables()->create([
@@ -3232,6 +3235,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
+ 'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3255,6 +3259,9 @@ public function create_bulk_envs(Request $request)
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
+ if ($item->has('comment') && $env->comment != $item->get('comment')) {
+ $env->comment = $item->get('comment');
+ }
$env->save();
} else {
$env = $application->environment_variables()->create([
@@ -3266,6 +3273,7 @@ public function create_bulk_envs(Request $request)
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
+ 'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3353,7 +3361,7 @@ public function create_env(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
- $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3510,7 +3518,7 @@ public function delete_env_by_uuid(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
- $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3520,7 +3528,7 @@ public function delete_env_by_uuid(Request $request)
$this->authorize('manageEnvironment', $application);
- $found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
+ $found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->first();
@@ -3919,4 +3927,260 @@ private function validateDataApplications(Request $request, Server $server)
}
}
}
+
+ #[OA\Get(
+ summary: 'List Storages',
+ description: 'List all persistent storages and file storages by application UUID.',
+ path: '/applications/{uuid}/storages',
+ operationId: 'list-storages-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'All storages by application UUID.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
+ new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
+ ],
+ ),
+ ),
+ 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 storages(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+
+ if (! $application) {
+ return response()->json([
+ 'message' => 'Application not found',
+ ], 404);
+ }
+
+ $this->authorize('view', $application);
+
+ $persistentStorages = $application->persistentStorages->sortBy('id')->values();
+ $fileStorages = $application->fileStorages->sortBy('id')->values();
+
+ return response()->json([
+ 'persistent_storages' => $persistentStorages,
+ 'file_storages' => $fileStorages,
+ ]);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Storage',
+ description: 'Update a persistent storage or file storage by application UUID.',
+ path: '/applications/{uuid}/storages',
+ operationId: 'update-storage-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['id', 'type'],
+ properties: [
+ 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'],
+ 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
+ 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
+ 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
+ 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
+ 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
+ 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
+ ],
+ additionalProperties: false,
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Storage updated.',
+ content: new OA\JsonContent(type: 'object'),
+ ),
+ 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',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update_storage(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+
+ if (! $application) {
+ return response()->json([
+ 'message' => 'Application not found',
+ ], 404);
+ }
+
+ $this->authorize('update', $application);
+
+ $validator = customApiValidator($request->all(), [
+ 'id' => 'required|integer',
+ 'type' => 'required|string|in:persistent,file',
+ 'is_preview_suffix_enabled' => 'boolean',
+ 'name' => 'string',
+ 'mount_path' => 'string',
+ 'host_path' => 'string|nullable',
+ 'content' => 'string|nullable',
+ ]);
+
+ $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
+ $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ if ($request->type === 'persistent') {
+ $storage = $application->persistentStorages->where('id', $request->id)->first();
+ } else {
+ $storage = $application->fileStorages->where('id', $request->id)->first();
+ }
+
+ if (! $storage) {
+ return response()->json([
+ 'message' => 'Storage not found.',
+ ], 404);
+ }
+
+ $isReadOnly = $storage->shouldBeReadOnlyInUI();
+ $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
+ $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
+
+ if ($isReadOnly && ! empty($requestedEditableFields)) {
+ return response()->json([
+ 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
+ 'read_only_fields' => array_values($requestedEditableFields),
+ ], 422);
+ }
+
+ // Reject fields that don't apply to the given storage type
+ if (! $isReadOnly) {
+ $typeSpecificInvalidFields = $request->type === 'persistent'
+ ? array_intersect(['content'], array_keys($request->all()))
+ : array_intersect(['name', 'host_path'], array_keys($request->all()));
+
+ if (! empty($typeSpecificInvalidFields)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => collect($typeSpecificInvalidFields)
+ ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
+ ], 422);
+ }
+ }
+
+ // Always allowed
+ if ($request->has('is_preview_suffix_enabled')) {
+ $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
+ }
+
+ // Only for editable storages
+ if (! $isReadOnly) {
+ if ($request->type === 'persistent') {
+ if ($request->has('name')) {
+ $storage->name = $request->name;
+ }
+ if ($request->has('mount_path')) {
+ $storage->mount_path = $request->mount_path;
+ }
+ if ($request->has('host_path')) {
+ $storage->host_path = $request->host_path;
+ }
+ } else {
+ if ($request->has('mount_path')) {
+ $storage->mount_path = $request->mount_path;
+ }
+ if ($request->has('content')) {
+ $storage->content = $request->content;
+ }
+ }
+ }
+
+ $storage->save();
+
+ return response()->json($storage);
+ }
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index f7a62cf90..6ad18d872 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -11,6 +11,7 @@
use App\Http\Controllers\Controller;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
+use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
@@ -2750,4 +2751,551 @@ public function action_restart(Request $request)
200
);
}
+
+ private function removeSensitiveEnvData($env)
+ {
+ $env->makeHidden([
+ 'id',
+ 'resourceable',
+ 'resourceable_id',
+ 'resourceable_type',
+ ]);
+ if (request()->attributes->get('can_read_sensitive', false) === false) {
+ $env->makeHidden([
+ 'value',
+ 'real_value',
+ ]);
+ }
+
+ return serializeApiResponse($env);
+ }
+
+ #[OA\Get(
+ summary: 'List Envs',
+ description: 'List all envs by database UUID.',
+ path: '/databases/{uuid}/envs',
+ operationId: 'list-envs-by-database-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',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Environment variables.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
+ )
+ ),
+ ]
+ ),
+ 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 envs(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('view', $database);
+
+ $envs = $database->environment_variables->map(function ($env) {
+ return $this->removeSensitiveEnvData($env);
+ });
+
+ return response()->json($envs);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Env',
+ description: 'Update env by database UUID.',
+ path: '/databases/{uuid}/envs',
+ operationId: 'update-env-by-database-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',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Env updated.',
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['key', 'value'],
+ properties: [
+ 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
+ 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
+ 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
+ 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
+ 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
+ ],
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Environment variable updated.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ ref: '#/components/schemas/EnvironmentVariable'
+ )
+ ),
+ ]
+ ),
+ 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',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update_env_by_uuid(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('manageEnvironment', $database);
+
+ $validator = customApiValidator($request->all(), [
+ 'key' => 'string|required',
+ 'value' => 'string|nullable',
+ 'is_literal' => 'boolean',
+ 'is_multiline' => 'boolean',
+ 'is_shown_once' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $key = str($request->key)->trim()->replace(' ', '_')->value;
+ $env = $database->environment_variables()->where('key', $key)->first();
+ if (! $env) {
+ return response()->json(['message' => 'Environment variable not found.'], 404);
+ }
+
+ $env->value = $request->value;
+ if ($request->has('is_literal')) {
+ $env->is_literal = $request->is_literal;
+ }
+ if ($request->has('is_multiline')) {
+ $env->is_multiline = $request->is_multiline;
+ }
+ if ($request->has('is_shown_once')) {
+ $env->is_shown_once = $request->is_shown_once;
+ }
+ if ($request->has('comment')) {
+ $env->comment = $request->comment;
+ }
+ $env->save();
+
+ return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Envs (Bulk)',
+ description: 'Update multiple envs by database UUID.',
+ path: '/databases/{uuid}/envs/bulk',
+ operationId: 'update-envs-by-database-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',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Bulk envs updated.',
+ required: true,
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['data'],
+ properties: [
+ 'data' => [
+ 'type' => 'array',
+ 'items' => new OA\Schema(
+ type: 'object',
+ properties: [
+ 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
+ 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
+ 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
+ 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
+ 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
+ ],
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Environment variables updated.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
+ )
+ ),
+ ]
+ ),
+ 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',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function create_bulk_envs(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('manageEnvironment', $database);
+
+ $bulk_data = $request->get('data');
+ if (! $bulk_data) {
+ return response()->json(['message' => 'Bulk data is required.'], 400);
+ }
+
+ $updatedEnvs = collect();
+ foreach ($bulk_data as $item) {
+ $validator = customApiValidator($item, [
+ 'key' => 'string|required',
+ 'value' => 'string|nullable',
+ 'is_literal' => 'boolean',
+ 'is_multiline' => 'boolean',
+ 'is_shown_once' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+ $key = str($item['key'])->trim()->replace(' ', '_')->value;
+ $env = $database->environment_variables()->updateOrCreate(
+ ['key' => $key],
+ $item
+ );
+
+ $updatedEnvs->push($this->removeSensitiveEnvData($env));
+ }
+
+ return response()->json($updatedEnvs)->setStatusCode(201);
+ }
+
+ #[OA\Post(
+ summary: 'Create Env',
+ description: 'Create env by database UUID.',
+ path: '/databases/{uuid}/envs',
+ operationId: 'create-env-by-database-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',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ description: 'Env created.',
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
+ 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
+ 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
+ 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
+ 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Environment variable created.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string', 'example' => 'nc0k04gk8g0cgsk440g0koko'],
+ ]
+ )
+ ),
+ ]
+ ),
+ 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',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function create_env(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('manageEnvironment', $database);
+
+ $validator = customApiValidator($request->all(), [
+ 'key' => 'string|required',
+ 'value' => 'string|nullable',
+ 'is_literal' => 'boolean',
+ 'is_multiline' => 'boolean',
+ 'is_shown_once' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $key = str($request->key)->trim()->replace(' ', '_')->value;
+ $existingEnv = $database->environment_variables()->where('key', $key)->first();
+ if ($existingEnv) {
+ return response()->json([
+ 'message' => 'Environment variable already exists. Use PATCH request to update it.',
+ ], 409);
+ }
+
+ $env = $database->environment_variables()->create([
+ 'key' => $key,
+ 'value' => $request->value,
+ 'is_literal' => $request->is_literal ?? false,
+ 'is_multiline' => $request->is_multiline ?? false,
+ 'is_shown_once' => $request->is_shown_once ?? false,
+ 'comment' => $request->comment ?? null,
+ ]);
+
+ return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Env',
+ description: 'Delete env by UUID.',
+ path: '/databases/{uuid}/envs/{env_uuid}',
+ operationId: 'delete-env-by-database-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',
+ )
+ ),
+ new OA\Parameter(
+ name: 'env_uuid',
+ in: 'path',
+ description: 'UUID of the environment variable.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Environment variable deleted.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'message' => ['type' => 'string', 'example' => 'Environment variable deleted.'],
+ ]
+ )
+ ),
+ ]
+ ),
+ 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 delete_env_by_uuid(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
+ if (! $database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('manageEnvironment', $database);
+
+ $env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
+ ->where('resourceable_type', get_class($database))
+ ->where('resourceable_id', $database->id)
+ ->first();
+
+ if (! $env) {
+ return response()->json(['message' => 'Environment variable not found.'], 404);
+ }
+
+ $env->forceDelete();
+
+ return response()->json(['message' => 'Environment variable deleted.']);
+ }
}
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 892457925..da94521a8 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -7,6 +7,7 @@
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
+use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -758,12 +759,22 @@ public function delete_server(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
- if ($server->definedResources()->count() > 0) {
- return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
+
+ $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
+
+ if ($server->definedResources()->count() > 0 && ! $force) {
+ return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
+
+ if ($force) {
+ foreach ($server->definedResources() as $resource) {
+ DeleteResourceJob::dispatch($resource);
+ }
+ }
+
$server->delete();
DeleteServer::dispatch(
$server->id,
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index b4fe4e47b..4caee26dd 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -222,6 +222,7 @@ public function services(Request $request)
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
+ 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
),
),
@@ -288,7 +289,7 @@ public function services(Request $request)
)]
public function create_service(Request $request)
{
- $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override'];
+ $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,6 +318,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -429,6 +431,9 @@ public function create_service(Request $request)
$service = Service::create($servicePayload);
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
+ if ($request->has('is_container_label_escape_enabled')) {
+ $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
+ }
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -485,7 +490,7 @@ public function create_service(Request $request)
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
- $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
+ $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -503,6 +508,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -609,6 +615,9 @@ public function create_service(Request $request)
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
+ if ($request->has('is_container_label_escape_enabled')) {
+ $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
+ }
$service->save();
$service->parse(isNew: true);
@@ -835,6 +844,7 @@ public function delete_by_uuid(Request $request)
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
+ 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -923,7 +933,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $service);
- $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
+ $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -936,6 +946,7 @@ public function update_by_uuid(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -1001,6 +1012,9 @@ public function update_by_uuid(Request $request)
if ($request->has('connect_to_docker_network')) {
$service->connect_to_docker_network = $request->connect_to_docker_network;
}
+ if ($request->has('is_container_label_escape_enabled')) {
+ $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
+ }
$service->save();
$service->parse();
@@ -1193,7 +1207,7 @@ public function update_env_by_uuid(Request $request)
return invalidTokenResponse();
}
- $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1328,7 +1342,7 @@ public function create_bulk_envs(Request $request)
return invalidTokenResponse();
}
- $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1348,6 +1362,7 @@ public function create_bulk_envs(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1447,7 +1462,7 @@ public function create_env(Request $request)
return invalidTokenResponse();
}
- $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1556,14 +1571,14 @@ public function delete_env_by_uuid(Request $request)
return invalidTokenResponse();
}
- $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('manageEnvironment', $service);
- $env = EnvironmentVariable::where('uuid', $request->env_uuid)
+ $env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Service::class)
->where('resourceable_id', $service->id)
->first();
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index fcd619fd4..e30af5cc7 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -223,7 +223,11 @@ public function __construct(public int $application_deployment_queue_id)
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
- $this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/');
+ $baseDir = $this->application->base_directory;
+ if ($baseDir && $baseDir !== '/') {
+ $this->validatePathField($baseDir, 'base_directory');
+ }
+ $this->workdir = "{$this->basedir}".rtrim($baseDir, '/');
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
@@ -312,7 +316,11 @@ public function handle(): void
}
if ($this->application->dockerfile_target_build) {
- $this->buildTarget = " --target {$this->application->dockerfile_target_build} ";
+ $target = $this->application->dockerfile_target_build;
+ if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
+ throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
+ }
+ $this->buildTarget = " --target {$target} ";
}
// Check custom port
@@ -571,12 +579,15 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
+ $this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command');
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
- $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
+ $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
+ $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
+ $this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command');
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
$this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
@@ -1102,10 +1113,21 @@ private function generate_image_names()
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
+
+ // Restart doesn't need the build server — disable it so the helper container
+ // is created on the deployment server with the correct network/flags.
+ $originalUseBuildServer = $this->use_build_server;
+ $this->use_build_server = false;
+
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->generate_image_names();
$this->check_image_locally_or_remotely();
+
+ // Restore before should_skip_build() — it may re-enter decide_what_to_do()
+ // for a full rebuild which needs the build server.
+ $this->use_build_server = $originalUseBuildServer;
+
$this->should_skip_build();
$this->completeDeployment();
}
@@ -2744,7 +2766,8 @@ private function generate_local_persistent_volumes()
} else {
$volume_name = $persistentStorage->name;
}
- if ($this->pull_request_id !== 0) {
+ $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
+ if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
}
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
@@ -2762,7 +2785,8 @@ private function generate_local_persistent_volumes_only_volume_names()
}
$name = $persistentStorage->name;
- if ($this->pull_request_id !== 0) {
+ $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
+ if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
}
@@ -2780,9 +2804,15 @@ private function generate_healthcheck_commands()
// Handle CMD type healthcheck
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
- $this->full_healthcheck_url = $command;
- return $command;
+ // Defense in depth: validate command at runtime (matches input validation regex)
+ if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) {
+ $this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.');
+ } else {
+ $this->full_healthcheck_url = $command;
+
+ return $command;
+ }
}
// HTTP type healthcheck (default)
@@ -2803,16 +2833,16 @@ private function generate_healthcheck_commands()
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
- $method = escapeshellarg($method);
+ $escapedMethod = escapeshellarg($method);
if ($path) {
- $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
+ $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
- $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
+ $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
}
$generated_healthchecks_commands = [
- "curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
+ "curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
];
return implode(' ', $generated_healthchecks_commands);
@@ -3939,6 +3969,24 @@ private function validatePathField(string $value, string $fieldName): string
return $value;
}
+ private function validateShellSafeCommand(string $value, string $fieldName): string
+ {
+ if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
+ throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
+ }
+
+ return $value;
+ }
+
+ private function validateContainerName(string $value): string
+ {
+ if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
+ throw new \RuntimeException('Invalid container name: contains forbidden characters.');
+ }
+
+ return $value;
+ }
+
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@@ -3952,7 +4000,17 @@ private function run_pre_deployment_command()
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $this->validateContainerName($containerName);
+ }
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
+ // Security: pre_deployment_command is intentionally treated as arbitrary shell input.
+ // Users (team members with deployment access) need full shell flexibility to run commands
+ // like "php artisan migrate", "npm run build", etc. inside their own application containers.
+ // The trust boundary is at the application/team ownership level — only authenticated team
+ // members can set these commands, and execution is scoped to the application's own container.
+ // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
+ // restrict the command itself. Container names are validated separately via validateContainerName().
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
@@ -3979,7 +4037,12 @@ private function run_post_deployment_command()
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $this->validateContainerName($containerName);
+ }
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
+ // Security: post_deployment_command is intentionally treated as arbitrary shell input.
+ // See the equivalent comment in run_pre_deployment_command() for the full security rationale.
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
try {
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 5fc9f6cd8..b55c324be 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -625,10 +625,16 @@ private function calculate_size()
private function upload_to_s3(): void
{
+ if (is_null($this->s3)) {
+ $this->backup->update([
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
+ }
+
try {
- if (is_null($this->s3)) {
- return;
- }
$key = $this->s3->key;
$secret = $this->s3->secret;
// $region = $this->s3->region;
diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php
index 78ef7f3a2..a8a3cb159 100644
--- a/app/Jobs/DockerCleanupJob.php
+++ b/app/Jobs/DockerCleanupJob.php
@@ -39,7 +39,9 @@ public function __construct(
public bool $manualCleanup = false,
public bool $deleteUnusedVolumes = false,
public bool $deleteUnusedNetworks = false
- ) {}
+ ) {
+ $this->onQueue('high');
+ }
public function handle(): void
{
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index e68e3b613..ebcd229ed 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -350,7 +350,7 @@ private function shouldRunNow(string $frequency, string $timezone, ?string $dedu
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
- // No dedup key → simple isDue check (used by docker cleanups)
+ // No dedup key → simple isDue check
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
@@ -411,7 +411,7 @@ private function processDockerCleanups(): void
}
// Use the frozen execution time for consistent evaluation
- if ($this->shouldRunNow($frequency, $serverTimezone)) {
+ if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) {
DockerCleanupJob::dispatch(
$server,
false,
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index d4a499865..2c73ae43e 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -108,10 +108,6 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
- Log::warning('ServerConnectionCheckJob timed out', [
- 'server_id' => $this->server->id,
- 'server_name' => $this->server->name,
- ]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
@@ -131,11 +127,8 @@ private function checkHetznerStatus(): void
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
- } catch (\Throwable $e) {
- Log::debug('ServerConnectionCheck: Hetzner status check failed', [
- 'server_id' => $this->server->id,
- 'error' => $e->getMessage(),
- ]);
+ } catch (\Throwable) {
+ // Silently ignore — server may have been deleted from Hetzner.
}
if ($this->server->hetzner_server_status !== $status) {
$this->server->update(['hetzner_server_status' => $status]);
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index 730ce547d..d56ff0a8c 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -14,6 +14,7 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
@@ -80,7 +81,7 @@ private function getServers(): Collection
private function dispatchConnectionChecks(Collection $servers): void
{
- if ($this->shouldRunNow($this->checkFrequency)) {
+ if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) {
$servers->each(function (Server $server) {
try {
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
@@ -129,13 +130,13 @@ private function processServerTasks(Server $server): void
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
- if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
+ if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) {
ServerCheckJob::dispatch($server);
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
- $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
+ $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}");
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
@@ -149,7 +150,7 @@ private function processServerTasks(Server $server): void
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
- $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
+ $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}");
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
@@ -157,7 +158,7 @@ private function processServerTasks(Server $server): void
}
// Dispatch ServerPatchCheckJob if due (weekly)
- $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
+ $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}");
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
@@ -167,7 +168,14 @@ private function processServerTasks(Server $server): void
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
- private function shouldRunNow(string $frequency, ?string $timezone = null): bool
+ /**
+ * Determine if a cron schedule should run now.
+ *
+ * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
+ * instead of isDue(). This is resilient to queue delays — even if the job is delayed
+ * by minutes, it still catches the missed cron window.
+ */
+ private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool
{
$cron = new CronExpression($frequency);
@@ -175,6 +183,29 @@ private function shouldRunNow(string $frequency, ?string $timezone = null): bool
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
- return $cron->isDue($executionTime);
+ if ($dedupKey === null) {
+ return $cron->isDue($executionTime);
+ }
+
+ $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+
+ $lastDispatched = Cache::get($dedupKey);
+
+ if ($lastDispatched === null) {
+ $isDue = $cron->isDue($executionTime);
+ if ($isDue) {
+ Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
+ }
+
+ return $isDue;
+ }
+
+ if ($previousDue->gt(Carbon::parse($lastDispatched))) {
+ Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
+
+ return true;
+ }
+
+ return false;
}
}
diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php
index e61ac81e4..f5d52f29c 100644
--- a/app/Jobs/StripeProcessJob.php
+++ b/app/Jobs/StripeProcessJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -238,6 +239,7 @@ public function handle(): void
'stripe_invoice_paid' => false,
]);
}
+ break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
$userId = data_get($data, 'metadata.user_id');
@@ -272,14 +274,14 @@ public function handle(): void
$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 = data_get($data, 'items.data.0.quantity', 2);
+ $quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
'custom_server_limit' => $quantity,
]);
+ ServerLimitCheckJob::dispatch($team);
}
- ServerLimitCheckJob::dispatch($team);
}
$subscription->update([
'stripe_feedback' => $feedback,
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index 9f02f9b78..288904471 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -179,6 +179,9 @@ public function handle(): void
// Mark validation as complete
$this->server->update(['is_validating' => false]);
+ // Auto-fetch server details now that validation passed
+ $this->server->gatherServerMetadata();
+
// Refresh server to get latest state
$this->server->refresh();
diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php
index cf7c3c0ea..f7addacf1 100644
--- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php
+++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php
@@ -82,12 +82,9 @@ public function handle(): void
'stripe_past_due' => false,
]);
- // Trigger subscription ended logic if canceled
- if ($stripeSubscription->status === 'canceled') {
- $team = $this->subscription->team;
- if ($team) {
- $team->subscriptionEnded();
- }
+ $team = $this->subscription->team;
+ if ($team) {
+ $team->subscriptionEnded();
}
break;
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index b3fe99806..ca1daef72 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -7,7 +7,6 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
-use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -22,136 +21,95 @@ class General extends Component
public Collection $services;
- #[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
- #[Validate(['string', 'nullable'])]
public ?string $description = null;
- #[Validate(['nullable'])]
public ?string $fqdn = null;
- #[Validate(['required'])]
public string $gitRepository;
- #[Validate(['required'])]
public string $gitBranch;
- #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
- #[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
- #[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
- #[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
- #[Validate(['required'])]
public string $buildPack;
- #[Validate(['required'])]
public string $staticImage;
- #[Validate(['required'])]
public string $baseDirectory;
- #[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
- #[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
- #[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
- #[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
- #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerfileLocation = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
- #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerComposeLocation = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
- #[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
- #[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
- #[Validate(['string', 'nullable'])]
+ // Security: pre/post deployment commands are intentionally arbitrary shell — users need full
+ // flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization.
+ // Commands execute inside the application's own container, not on the host.
public ?string $preDeploymentCommand = null;
- #[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
- #[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
- #[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
- #[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
- #[Validate(['boolean', 'required'])]
public bool $isStatic = false;
- #[Validate(['boolean', 'required'])]
public bool $isSpa = false;
- #[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
- #[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
- #[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
- #[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
- #[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
- #[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
- #[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
- #[Validate(['nullable'])]
public ?string $watchPaths = null;
- #[Validate(['string', 'required'])]
public string $redirect;
- #[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@@ -184,33 +142,33 @@ protected function rules(): array
'fqdn' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
- 'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
+ 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',
'buildPack' => 'required',
'staticImage' => 'required',
- 'baseDirectory' => 'required',
- 'publishDirectory' => 'nullable',
+ 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
+ 'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => 'required',
'portsMappings' => 'nullable',
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable',
- 'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
- 'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
+ 'dockerfileLocation' => ValidationPatterns::filePathRules(),
+ 'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable',
- 'dockerfileTargetBuild' => 'nullable',
- 'dockerComposeCustomStartCommand' => 'nullable',
- 'dockerComposeCustomBuildCommand' => 'nullable',
+ 'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(),
+ 'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(),
+ 'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(),
'customLabels' => 'nullable',
- 'customDockerRunOptions' => 'nullable',
+ 'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000),
'preDeploymentCommand' => 'nullable',
- 'preDeploymentCommandContainer' => 'nullable',
+ 'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'postDeploymentCommand' => 'nullable',
- 'postDeploymentCommandContainer' => 'nullable',
+ 'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'customNginxConfiguration' => 'nullable',
'isStatic' => 'boolean|required',
'isSpa' => 'boolean|required',
@@ -233,6 +191,14 @@ protected function messages(): array
[
...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'),
...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'),
+ 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
+ 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
+ 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
+ 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
+ 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
+ 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
+ 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
+ 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',
diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php
index be6e3e98f..094b61b28 100644
--- a/app/Livewire/Project/Resource/Index.php
+++ b/app/Livewire/Project/Resource/Index.php
@@ -13,33 +13,33 @@ class Index extends Component
public Environment $environment;
- public Collection $applications;
-
- public Collection $postgresqls;
-
- public Collection $redis;
-
- public Collection $mongodbs;
-
- public Collection $mysqls;
-
- public Collection $mariadbs;
-
- public Collection $keydbs;
-
- public Collection $dragonflies;
-
- public Collection $clickhouses;
-
- public Collection $services;
-
public Collection $allProjects;
public Collection $allEnvironments;
public array $parameters;
- public function mount()
+ protected Collection $applications;
+
+ protected Collection $postgresqls;
+
+ protected Collection $redis;
+
+ protected Collection $mongodbs;
+
+ protected Collection $mysqls;
+
+ protected Collection $mariadbs;
+
+ protected Collection $keydbs;
+
+ protected Collection $dragonflies;
+
+ protected Collection $clickhouses;
+
+ protected Collection $services;
+
+ public function mount(): void
{
$this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect();
$this->parameters = get_route_parameters();
@@ -55,31 +55,23 @@ public function mount()
$this->project = $project;
- // Load projects and environments for breadcrumb navigation (avoids inline queries in view)
+ // Load projects and environments for breadcrumb navigation
$this->allProjects = Project::ownedByCurrentTeamCached();
$this->allEnvironments = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
->with([
- 'applications.additional_servers',
- 'applications.destination.server',
- 'services',
- 'services.destination.server',
- 'postgresqls',
- 'postgresqls.destination.server',
- 'redis',
- 'redis.destination.server',
- 'mongodbs',
- 'mongodbs.destination.server',
- 'mysqls',
- 'mysqls.destination.server',
- 'mariadbs',
- 'mariadbs.destination.server',
- 'keydbs',
- 'keydbs.destination.server',
- 'dragonflies',
- 'dragonflies.destination.server',
- 'clickhouses',
- 'clickhouses.destination.server',
- ])->get();
+ 'applications:id,uuid,name,environment_id',
+ 'services:id,uuid,name,environment_id',
+ 'postgresqls:id,uuid,name,environment_id',
+ 'redis:id,uuid,name,environment_id',
+ 'mongodbs:id,uuid,name,environment_id',
+ 'mysqls:id,uuid,name,environment_id',
+ 'mariadbs:id,uuid,name,environment_id',
+ 'keydbs:id,uuid,name,environment_id',
+ 'dragonflies:id,uuid,name,environment_id',
+ 'clickhouses:id,uuid,name,environment_id',
+ ])
+ ->get();
$this->environment = $environment->loadCount([
'applications',
@@ -94,11 +86,9 @@ public function mount()
'services',
]);
- // Eager load all relationships for applications including nested ones
+ // Eager load relationships for applications
$this->applications = $this->environment->applications()->with([
'tags',
- 'additional_servers.settings',
- 'additional_networks',
'destination.server.settings',
'settings',
])->get()->sortBy('name');
@@ -160,6 +150,49 @@ public function mount()
public function render()
{
- return view('livewire.project.resource.index');
+ return view('livewire.project.resource.index', [
+ 'applications' => $this->applications,
+ 'postgresqls' => $this->postgresqls,
+ 'redis' => $this->redis,
+ 'mongodbs' => $this->mongodbs,
+ 'mysqls' => $this->mysqls,
+ 'mariadbs' => $this->mariadbs,
+ 'keydbs' => $this->keydbs,
+ 'dragonflies' => $this->dragonflies,
+ 'clickhouses' => $this->clickhouses,
+ 'services' => $this->services,
+ 'applicationsJs' => $this->toSearchableArray($this->applications),
+ 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
+ 'redisJs' => $this->toSearchableArray($this->redis),
+ 'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
+ 'mysqlsJs' => $this->toSearchableArray($this->mysqls),
+ 'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
+ 'keydbsJs' => $this->toSearchableArray($this->keydbs),
+ 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
+ 'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
+ 'servicesJs' => $this->toSearchableArray($this->services),
+ ]);
+ }
+
+ private function toSearchableArray(Collection $items): array
+ {
+ return $items->map(fn ($item) => [
+ 'uuid' => $item->uuid,
+ 'name' => $item->name,
+ 'fqdn' => $item->fqdn ?? null,
+ 'description' => $item->description ?? null,
+ 'status' => $item->status ?? '',
+ 'server_status' => $item->server_status ?? null,
+ 'hrefLink' => $item->hrefLink ?? '',
+ 'destination' => [
+ 'server' => [
+ 'name' => $item->destination?->server?->name ?? 'Unknown',
+ ],
+ ],
+ 'tags' => $item->tags->map(fn ($tag) => [
+ 'id' => $tag->id,
+ 'name' => $tag->name,
+ ])->values()->toArray(),
+ ])->values()->toArray();
}
}
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 5d948bffd..844e37854 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -40,12 +40,16 @@ class FileStorage extends Component
#[Validate(['required', 'boolean'])]
public bool $isBasedOnGit = false;
+ #[Validate(['required', 'boolean'])]
+ public bool $isPreviewSuffixEnabled = true;
+
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
'fileStorage.mount_path' => 'required',
'content' => 'nullable',
'isBasedOnGit' => 'required|boolean',
+ 'isPreviewSuffixEnabled' => 'required|boolean',
];
public function mount()
@@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void
// Sync to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
+ $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
$this->fileStorage->save();
} else {
// Sync from model
$this->content = $this->fileStorage->content;
$this->isBasedOnGit = $this->fileStorage->is_based_on_git;
+ $this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true;
}
}
@@ -175,6 +181,7 @@ public function submit()
// Sync component properties to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
+ $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
@@ -187,9 +194,11 @@ public function submit()
}
}
- public function instantSave()
+ public function instantSave(): void
{
- $this->submit();
+ $this->authorize('update', $this->resource);
+ $this->syncData(true);
+ $this->dispatch('success', 'File updated.');
}
public function render()
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index 69395a591..eee5a0776 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -29,10 +29,13 @@ class Show extends Component
public ?string $hostPath = null;
+ public bool $isPreviewSuffixEnabled = true;
+
protected $rules = [
'name' => 'required|string',
'mountPath' => 'required|string',
'hostPath' => 'string|nullable',
+ 'isPreviewSuffixEnabled' => 'required|boolean',
];
protected $validationAttributes = [
@@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void
$this->storage->name = $this->name;
$this->storage->mount_path = $this->mountPath;
$this->storage->host_path = $this->hostPath;
+ $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->storage->name;
$this->mountPath = $this->storage->mount_path;
$this->hostPath = $this->storage->host_path;
+ $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
}
}
@@ -67,6 +72,16 @@ public function mount()
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
+ public function instantSave(): void
+ {
+ $this->authorize('update', $this->resource);
+ $this->validate();
+
+ $this->syncData(true);
+ $this->storage->save();
+ $this->dispatch('success', 'Storage updated successfully');
+ }
+
public function submit()
{
$this->authorize('update', $this->resource);
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index beb8c0a12..d06543b39 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
+use App\Jobs\DeleteResourceJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -15,6 +16,8 @@ class Delete extends Component
public bool $delete_from_hetzner = false;
+ public bool $force_delete_resources = false;
+
public function mount(string $server_uuid)
{
try {
@@ -32,15 +35,22 @@ public function delete($password, $selectedActions = [])
if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
+ $this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
$this->authorize('delete', $this->server);
- if ($this->server->hasDefinedResources()) {
- $this->dispatch('error', 'Server has defined resources. Please delete them first.');
+ if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
+ $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
return;
}
+ if ($this->force_delete_resources) {
+ foreach ($this->server->definedResources() as $resource) {
+ DeleteResourceJob::dispatch($resource);
+ }
+ }
+
$this->server->delete();
DeleteServer::dispatch(
$this->server->id,
@@ -60,6 +70,15 @@ public function render()
{
$checkboxes = [];
+ if ($this->server->hasDefinedResources()) {
+ $resourceCount = $this->server->definedResources()->count();
+ $checkboxes[] = [
+ 'id' => 'force_delete_resources',
+ 'label' => "Delete all resources ({$resourceCount} total)",
+ 'default_warning' => 'Server cannot be deleted while it has resources.',
+ ];
+ }
+
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 1a5bd381b..198d823b9 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -198,6 +198,9 @@ public function validateDockerVersion()
// Mark validation as complete
$this->server->update(['is_validating' => false]);
+ // Auto-fetch server details now that validation passed
+ $this->server->gatherServerMetadata();
+
$this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 0a38e6088..17323fdec 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -239,7 +239,7 @@ public function mount()
if (isCloud() && ! isDev()) {
$this->webhook_endpoint = config('app.url');
} else {
- $this->webhook_endpoint = $this->ipv4 ?? '';
+ $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? '';
$this->is_system_wide = $this->github_app->is_system_wide;
}
} catch (\Throwable $e) {
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 4dc0b6ae2..791226334 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -6,6 +6,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\On;
use Livewire\Component;
class Form extends Component
@@ -131,19 +132,7 @@ public function testConnection()
}
}
- public function delete()
- {
- try {
- $this->authorize('delete', $this->storage);
-
- $this->storage->delete();
-
- return redirect()->route('storage.index');
- } catch (\Throwable $e) {
- return handleError($e, $this);
- }
- }
-
+ #[On('submitStorage')]
public function submit()
{
try {
diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php
new file mode 100644
index 000000000..643ecb3eb
--- /dev/null
+++ b/app/Livewire/Storage/Resources.php
@@ -0,0 +1,85 @@
+storage->id)
+ ->where('save_s3', true)
+ ->get();
+
+ foreach ($backups as $backup) {
+ $this->selectedStorages[$backup->id] = $this->storage->id;
+ }
+ }
+
+ public function disableS3(int $backupId): void
+ {
+ $backup = ScheduledDatabaseBackup::findOrFail($backupId);
+
+ $backup->update([
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+
+ unset($this->selectedStorages[$backupId]);
+
+ $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
+ }
+
+ public function moveBackup(int $backupId): void
+ {
+ $backup = ScheduledDatabaseBackup::findOrFail($backupId);
+ $newStorageId = $this->selectedStorages[$backupId] ?? null;
+
+ if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
+ $this->dispatch('error', 'No change.', 'The backup is already using this storage.');
+
+ return;
+ }
+
+ $newStorage = S3Storage::where('id', $newStorageId)
+ ->where('team_id', $this->storage->team_id)
+ ->first();
+
+ if (! $newStorage) {
+ $this->dispatch('error', 'Storage not found.');
+
+ return;
+ }
+
+ $backup->update(['s3_storage_id' => $newStorage->id]);
+
+ unset($this->selectedStorages[$backupId]);
+
+ $this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
+ }
+
+ public function render()
+ {
+ $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
+ ->where('save_s3', true)
+ ->with('database')
+ ->get()
+ ->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id);
+
+ $allStorages = S3Storage::where('team_id', $this->storage->team_id)
+ ->orderBy('name')
+ ->get(['id', 'name', 'is_usable']);
+
+ return view('livewire.storage.resources', [
+ 'groupedBackups' => $backups,
+ 'allStorages' => $allStorages,
+ ]);
+ }
+}
diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php
index fdf3d0d28..dc5121e94 100644
--- a/app/Livewire/Storage/Show.php
+++ b/app/Livewire/Storage/Show.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
+use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -12,6 +13,10 @@ class Show extends Component
public $storage = null;
+ public string $currentRoute = '';
+
+ public int $backupCount = 0;
+
public function mount()
{
$this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first();
@@ -19,6 +24,21 @@ public function mount()
abort(404);
}
$this->authorize('view', $this->storage);
+ $this->currentRoute = request()->route()->getName();
+ $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count();
+ }
+
+ public function delete()
+ {
+ try {
+ $this->authorize('delete', $this->storage);
+
+ $this->storage->delete();
+
+ return redirect()->route('storage.index');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function render()
diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php
index 2d5392240..33eed3a6a 100644
--- a/app/Livewire/Subscription/Actions.php
+++ b/app/Livewire/Subscription/Actions.php
@@ -7,6 +7,7 @@
use App\Actions\Stripe\ResumeSubscription;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Team;
+use Carbon\Carbon;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Stripe\StripeClient;
@@ -31,10 +32,15 @@ class Actions extends Component
public bool $refundAlreadyUsed = false;
+ public string $billingInterval = 'monthly';
+
+ public ?string $nextBillingDate = null;
+
public function mount(): void
{
$this->server_limits = Team::serverLimit();
$this->quantity = (int) $this->server_limits;
+ $this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly';
}
public function loadPricePreview(int $quantity): void
@@ -198,6 +204,10 @@ private function checkRefundEligibility(): void
$result = (new RefundSubscription)->checkEligibility(currentTeam());
$this->isRefundEligible = $result['eligible'];
$this->refundDaysRemaining = $result['days_remaining'];
+
+ if ($result['current_period_end']) {
+ $this->nextBillingDate = Carbon::createFromTimestamp($result['current_period_end'])->format('M j, Y');
+ }
} catch (\Exception $e) {
\Log::warning('Refund eligibility check failed: '.$e->getMessage());
}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 82e4d6311..4cc2dcf74 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -1732,7 +1732,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->save();
if (str($e->getMessage())->contains('No such file')) {
- throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile
Check if you used the right extension (.yaml or .yml) in the compose file name.");
+ throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
if ($this->deploymentType() === 'deploy_key') {
@@ -1793,7 +1793,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->base_directory = $initialBaseDirectory;
$this->save();
- throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile
Check if you used the right extension (.yaml or .yml) in the compose file name.");
+ throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index 7373fdb16..3b7bf3030 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -166,6 +166,16 @@ public function generate_preview_fqdn_compose()
}
$this->docker_compose_domains = json_encode($docker_compose_domains);
+
+ // Populate fqdn from generated domains so webhook notifications can read it
+ $allDomains = collect($docker_compose_domains)
+ ->pluck('domain')
+ ->filter(fn ($d) => ! empty($d))
+ ->flatMap(fn ($d) => explode(',', $d))
+ ->implode(',');
+
+ $this->fqdn = ! empty($allDomains) ? $allDomains : null;
+
$this->save();
}
}
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 9d7095cb5..da58ed2f9 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel
// 'mount_path' => 'encrypted',
'content' => 'encrypted',
'is_directory' => 'boolean',
+ 'is_preview_suffix_enabled' => 'boolean',
];
use HasFactory;
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index 7126253ea..1721f4afe 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -10,6 +10,10 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
+ protected $casts = [
+ 'is_preview_suffix_enabled' => 'boolean',
+ ];
+
public function resource()
{
return $this->morphTo('resource');
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index 7163ae7b5..1521678f3 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -5,6 +5,7 @@
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use OpenApi\Attributes as OA;
@@ -65,6 +66,20 @@ protected static function booted()
}
});
+ static::saved(function ($key) {
+ if ($key->wasChanged('private_key')) {
+ try {
+ $key->storeInFileSystem();
+ refresh_server_connection($key);
+ } catch (\Exception $e) {
+ Log::error('Failed to resync SSH key after update', [
+ 'key_uuid' => $key->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ });
+
static::deleted(function ($key) {
self::deleteFromStorage($key);
});
@@ -185,29 +200,54 @@ public function storeInFileSystem()
{
$filename = "ssh_key@{$this->uuid}";
$disk = Storage::disk('ssh-keys');
+ $keyLocation = $this->getKeyLocation();
+ $lockFile = $keyLocation.'.lock';
// Ensure the storage directory exists and is writable
$this->ensureStorageDirectoryExists();
- // Attempt to store the private key
- $success = $disk->put($filename, $this->private_key);
-
- if (! $success) {
- throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
+ // Use file locking to prevent concurrent writes from corrupting the key
+ $lockHandle = fopen($lockFile, 'c');
+ if ($lockHandle === false) {
+ throw new \Exception("Failed to open lock file for SSH key: {$lockFile}");
}
- // Verify the file was actually created and has content
- if (! $disk->exists($filename)) {
- throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
- }
+ try {
+ if (! flock($lockHandle, LOCK_EX)) {
+ throw new \Exception("Failed to acquire lock for SSH key: {$keyLocation}");
+ }
- $storedContent = $disk->get($filename);
- if (empty($storedContent) || $storedContent !== $this->private_key) {
- $disk->delete($filename); // Clean up the bad file
- throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
- }
+ // Attempt to store the private key
+ $success = $disk->put($filename, $this->private_key);
- return $this->getKeyLocation();
+ if (! $success) {
+ throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$keyLocation}");
+ }
+
+ // Verify the file was actually created and has content
+ if (! $disk->exists($filename)) {
+ throw new \Exception("SSH key file was not created: {$keyLocation}");
+ }
+
+ $storedContent = $disk->get($filename);
+ if (empty($storedContent) || $storedContent !== $this->private_key) {
+ $disk->delete($filename); // Clean up the bad file
+ throw new \Exception("SSH key file content verification failed: {$keyLocation}");
+ }
+
+ // Ensure correct permissions for SSH (0600 required)
+ if (file_exists($keyLocation) && ! chmod($keyLocation, 0600)) {
+ Log::warning('Failed to set SSH key file permissions to 0600', [
+ 'key_uuid' => $this->uuid,
+ 'path' => $keyLocation,
+ ]);
+ }
+
+ return $keyLocation;
+ } finally {
+ flock($lockHandle, LOCK_UN);
+ fclose($lockHandle);
+ }
}
public static function deleteFromStorage(self $privateKey)
@@ -254,12 +294,6 @@ public function updatePrivateKey(array $data)
return DB::transaction(function () use ($data) {
$this->update($data);
- try {
- $this->storeInFileSystem();
- } catch (\Exception $e) {
- throw new \Exception('Failed to update SSH key: '.$e->getMessage());
- }
-
return $this;
});
}
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index 3aae55966..f395a065c 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -40,6 +40,13 @@ protected static function boot(): void
$storage->secret = trim($storage->secret);
}
});
+
+ static::deleting(function (S3Storage $storage) {
+ ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
+ 'save_s3' => false,
+ 's3_storage_id' => null,
+ ]);
+ });
}
public static function ownedByCurrentTeam(array $select = ['*'])
@@ -59,6 +66,11 @@ public function team()
return $this->belongsTo(Team::class);
}
+ public function scheduledBackups()
+ {
+ return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
+ }
+
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 508b9833b..527c744a5 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -135,7 +135,7 @@ protected static function booted()
$server->forceFill($payload);
});
static::saved(function ($server) {
- if ($server->privateKey?->isDirty()) {
+ if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
refresh_server_connection($server->privateKey);
}
});
diff --git a/app/Models/Team.php b/app/Models/Team.php
index e32526169..10b22b4e1 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -197,6 +197,10 @@ public function isAnyNotificationEnabled()
public function subscriptionEnded()
{
+ if (! $this->subscription) {
+ return;
+ }
+
$this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,
diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php
index 2be36d905..8859a9980 100644
--- a/app/Services/ContainerStatusAggregator.php
+++ b/app/Services/ContainerStatusAggregator.php
@@ -54,13 +54,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
$maxRestartCount = 0;
}
- if ($maxRestartCount > 1000) {
- Log::warning('High maxRestartCount detected', [
- 'maxRestartCount' => $maxRestartCount,
- 'containers' => $containerStatuses->count(),
- ]);
- }
-
if ($containerStatuses->isEmpty()) {
return 'exited';
}
@@ -138,13 +131,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
$maxRestartCount = 0;
}
- if ($maxRestartCount > 1000) {
- Log::warning('High maxRestartCount detected', [
- 'maxRestartCount' => $maxRestartCount,
- 'containers' => $containers->count(),
- ]);
- }
-
if ($containers->isEmpty()) {
return 'exited';
}
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 2ae1536da..fdf2b12a6 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -9,7 +9,7 @@ class ValidationPatterns
{
/**
* Pattern for names excluding all dangerous characters
- */
+ */
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
/**
@@ -23,6 +23,32 @@ class ValidationPatterns
*/
public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
+ /**
+ * Pattern for directory paths (base_directory, publish_directory, etc.)
+ * Like FILE_PATH_PATTERN but also allows bare "/" (root directory)
+ */
+ public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/';
+
+ /**
+ * Pattern for Docker build target names (multi-stage build stage names)
+ * Allows alphanumeric, dots, hyphens, and underscores
+ */
+ public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
+
+ /**
+ * Pattern for shell-safe command strings (docker compose commands, docker run options)
+ * Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns
+ * Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks
+ * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
+ */
+ public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/';
+
+ /**
+ * Pattern for Docker container names
+ * Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
+ */
+ public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
+
/**
* Get validation rules for name fields
*/
@@ -70,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength =
public static function nameMessages(): array
{
return [
- 'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &",
+ 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
@@ -105,6 +131,38 @@ public static function filePathMessages(string $field = 'dockerfileLocation', st
];
}
+ /**
+ * Get validation rules for directory path fields (base_directory, publish_directory)
+ */
+ public static function directoryPathRules(int $maxLength = 255): array
+ {
+ return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN];
+ }
+
+ /**
+ * Get validation rules for Docker build target fields
+ */
+ public static function dockerTargetRules(int $maxLength = 128): array
+ {
+ return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN];
+ }
+
+ /**
+ * Get validation rules for shell-safe command fields
+ */
+ public static function shellSafeCommandRules(int $maxLength = 1000): array
+ {
+ return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
+ }
+
+ /**
+ * Get validation rules for container name fields
+ */
+ public static function containerNameRules(int $maxLength = 255): array
+ {
+ return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
+ }
+
/**
* Get combined validation messages for both name and description fields
*/
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index a4ea6abe5..72e0adde8 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -3,6 +3,7 @@
namespace App\Traits;
use App\Enums\ApplicationDeploymentStatus;
+use App\Exceptions\DeploymentException;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use Carbon\Carbon;
@@ -103,7 +104,7 @@ public function execute_remote_command(...$commands)
try {
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
$commandExecuted = true;
- } catch (\RuntimeException $e) {
+ } catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
$errorMessage = $e->getMessage();
// Only retry if it's an SSH connection error and we haven't exhausted retries
@@ -233,7 +234,7 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$error = $process_result->output() ?: 'Command failed with no error output';
}
$redactedCommand = $this->redact_sensitive_info($command);
- throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
+ throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
}
}
}
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 43c074cd1..ec42761f7 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -101,8 +101,8 @@ function sharedDataApplications()
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
- 'base_directory' => 'string|nullable',
- 'publish_directory' => 'string|nullable',
+ 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
+ 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
@@ -125,21 +125,24 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
- 'custom_docker_run_options' => 'string|nullable',
+ 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000),
+ // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
+ // Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
- 'post_deployment_command_container' => 'string',
+ 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
- 'pre_deployment_command_container' => 'string',
+ 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
- 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
- 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
+ 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
+ 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
+ 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
- 'docker_compose_custom_start_command' => 'string|nullable',
- 'docker_compose_custom_build_command' => 'string|nullable',
+ 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
+ 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
];
}
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index e84df55f9..cd4928d63 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = replaceLocalSource($source, $mainDirectory);
- if ($isPullRequest) {
+ $isPreviewSuffixEnabled = $foundConfig
+ ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
+ : true;
+ if ($isPullRequest && $isPreviewSuffixEnabled) {
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
LocalFileVolume::updateOrCreate(
@@ -1315,19 +1318,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
- $uuid = $resource->uuid;
- $network = data_get($resource, 'destination.network');
+ $labelUuid = $resource->uuid;
+ $labelNetwork = data_get($resource, 'destination.network');
if ($isPullRequest) {
- $uuid = "{$resource->uuid}-{$pullRequestId}";
+ $labelUuid = "{$resource->uuid}-{$pullRequestId}";
}
if ($isPullRequest) {
- $network = "{$resource->destination->network}-{$pullRequestId}";
+ $labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
- uuid: $uuid,
+ uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@@ -1339,8 +1342,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
break;
case ProxyTypes::CADDY->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
- network: $network,
- uuid: $uuid,
+ network: $labelNetwork,
+ uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@@ -1354,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
} else {
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
- uuid: $uuid,
+ uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
@@ -1364,8 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
image: $image
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
- network: $network,
- uuid: $uuid,
+ network: $labelNetwork,
+ uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
diff --git a/config/constants.php b/config/constants.php
index 5cb924148..9c6454cae 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.468',
+ 'version' => '4.0.0-beta.469',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php
new file mode 100644
index 000000000..a1f1d9ea1
--- /dev/null
+++ b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php
@@ -0,0 +1,30 @@
+boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git');
+ });
+
+ Schema::table('local_persistent_volumes', function (Blueprint $table) {
+ $table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('local_file_volumes', function (Blueprint $table) {
+ $table->dropColumn('is_preview_suffix_enabled');
+ });
+
+ Schema::table('local_persistent_volumes', function (Blueprint $table) {
+ $table->dropColumn('is_preview_suffix_enabled');
+ });
+ }
+};
diff --git a/jean.json b/jean.json
index 402bcd02d..5cd8362d9 100644
--- a/jean.json
+++ b/jean.json
@@ -1,6 +1,13 @@
{
"scripts": {
"setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json",
+ "teardown": null,
"run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
- }
-}
\ No newline at end of file
+ },
+ "ports": [
+ {
+ "port": 8000,
+ "label": "Coolify UI"
+ }
+ ]
+}
diff --git a/openapi.json b/openapi.json
index 849dee363..d119176a1 100644
--- a/openapi.json
+++ b/openapi.json
@@ -3442,6 +3442,167 @@
]
}
},
+ "\/applications\/{uuid}\/storages": {
+ "get": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "List Storages",
+ "description": "List all persistent storages and file storages by application UUID.",
+ "operationId": "list-storages-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "All storages by application UUID.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "persistent_storages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "file_storages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Update Storage",
+ "description": "Update a persistent storage or file storage by application UUID.",
+ "operationId": "update-storage-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "id",
+ "type"
+ ],
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "The ID of the storage."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "persistent",
+ "file"
+ ],
+ "description": "The type of storage: persistent or file."
+ },
+ "is_preview_suffix_enabled": {
+ "type": "boolean",
+ "description": "Whether to add -pr-N suffix for preview deployments."
+ },
+ "name": {
+ "type": "string",
+ "description": "The volume name (persistent only, not allowed for read-only storages)."
+ },
+ "mount_path": {
+ "type": "string",
+ "description": "The container mount path (not allowed for read-only storages)."
+ },
+ "host_path": {
+ "type": "string",
+ "nullable": true,
+ "description": "The host path (persistent only, not allowed for read-only storages)."
+ },
+ "content": {
+ "type": "string",
+ "nullable": true,
+ "description": "The file content (file only, not allowed for read-only storages)."
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Storage updated.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/cloud-tokens": {
"get": {
"tags": [
@@ -5971,6 +6132,387 @@
]
}
},
+ "\/databases\/{uuid}\/envs": {
+ "get": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "List Envs",
+ "description": "List all envs by database UUID.",
+ "operationId": "list-envs-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Environment variables.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/EnvironmentVariable"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Create Env",
+ "description": "Create env by database UUID.",
+ "operationId": "create-env-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Env created.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "key": {
+ "type": "string",
+ "description": "The key of the environment variable."
+ },
+ "value": {
+ "type": "string",
+ "description": "The value of the environment variable."
+ },
+ "is_literal": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable is a literal, nothing espaced."
+ },
+ "is_multiline": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable is multiline."
+ },
+ "is_shown_once": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable's value is shown on the UI."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Environment variable created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "example": "nc0k04gk8g0cgsk440g0koko"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Update Env",
+ "description": "Update env by database UUID.",
+ "operationId": "update-env-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Env updated.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "key",
+ "value"
+ ],
+ "properties": {
+ "key": {
+ "type": "string",
+ "description": "The key of the environment variable."
+ },
+ "value": {
+ "type": "string",
+ "description": "The value of the environment variable."
+ },
+ "is_literal": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable is a literal, nothing espaced."
+ },
+ "is_multiline": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable is multiline."
+ },
+ "is_shown_once": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable's value is shown on the UI."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Environment variable updated.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/EnvironmentVariable"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/databases\/{uuid}\/envs\/bulk": {
+ "patch": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Update Envs (Bulk)",
+ "description": "Update multiple envs by database UUID.",
+ "operationId": "update-envs-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Bulk envs updated.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "data"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "key": {
+ "type": "string",
+ "description": "The key of the environment variable."
+ },
+ "value": {
+ "type": "string",
+ "description": "The value of the environment variable."
+ },
+ "is_literal": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable is a literal, nothing espaced."
+ },
+ "is_multiline": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable is multiline."
+ },
+ "is_shown_once": {
+ "type": "boolean",
+ "description": "The flag to indicate if the environment variable's value is shown on the UI."
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Environment variables updated.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/EnvironmentVariable"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/databases\/{uuid}\/envs\/{env_uuid}": {
+ "delete": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Delete Env",
+ "description": "Delete env by UUID.",
+ "operationId": "delete-env-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "env_uuid",
+ "in": "path",
+ "description": "UUID of the environment variable.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Environment variable deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Environment variable deleted."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/deployments": {
"get": {
"tags": [
@@ -9685,6 +10227,11 @@
"type": "boolean",
"default": false,
"description": "Force domain override even if conflicts are detected."
+ },
+ "is_container_label_escape_enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
}
},
"type": "object"
@@ -10011,6 +10558,11 @@
"type": "boolean",
"default": false,
"description": "Force domain override even if conflicts are detected."
+ },
+ "is_container_label_escape_enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
}
},
"type": "object"
diff --git a/openapi.yaml b/openapi.yaml
index 226295cdb..7064be28a 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2170,6 +2170,108 @@ paths:
security:
-
bearerAuth: []
+ '/applications/{uuid}/storages':
+ get:
+ tags:
+ - Applications
+ summary: 'List Storages'
+ description: 'List all persistent storages and file storages by application UUID.'
+ operationId: list-storages-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'All storages by application UUID.'
+ content:
+ application/json:
+ schema:
+ properties:
+ persistent_storages: { type: array, items: { type: object } }
+ file_storages: { type: array, items: { type: object } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - Applications
+ summary: 'Update Storage'
+ description: 'Update a persistent storage or file storage by application UUID.'
+ operationId: update-storage-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - id
+ - type
+ properties:
+ id:
+ type: integer
+ description: 'The ID of the storage.'
+ type:
+ type: string
+ enum: [persistent, file]
+ description: 'The type of storage: persistent or file.'
+ is_preview_suffix_enabled:
+ type: boolean
+ description: 'Whether to add -pr-N suffix for preview deployments.'
+ name:
+ type: string
+ description: 'The volume name (persistent only, not allowed for read-only storages).'
+ mount_path:
+ type: string
+ description: 'The container mount path (not allowed for read-only storages).'
+ host_path:
+ type: string
+ nullable: true
+ description: 'The host path (persistent only, not allowed for read-only storages).'
+ content:
+ type: string
+ nullable: true
+ description: 'The file content (file only, not allowed for read-only storages).'
+ type: object
+ additionalProperties: false
+ responses:
+ '200':
+ description: 'Storage updated.'
+ content:
+ application/json:
+ schema:
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/cloud-tokens:
get:
tags:
@@ -3871,6 +3973,242 @@ paths:
security:
-
bearerAuth: []
+ '/databases/{uuid}/envs':
+ get:
+ tags:
+ - Databases
+ summary: 'List Envs'
+ description: 'List all envs by database UUID.'
+ operationId: list-envs-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Environment variables.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/EnvironmentVariable'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Databases
+ summary: 'Create Env'
+ description: 'Create env by database UUID.'
+ operationId: create-env-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Env created.'
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ key:
+ type: string
+ description: 'The key of the environment variable.'
+ value:
+ type: string
+ description: 'The value of the environment variable.'
+ is_literal:
+ type: boolean
+ description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
+ is_multiline:
+ type: boolean
+ description: 'The flag to indicate if the environment variable is multiline.'
+ is_shown_once:
+ type: boolean
+ description: "The flag to indicate if the environment variable's value is shown on the UI."
+ type: object
+ responses:
+ '201':
+ description: 'Environment variable created.'
+ content:
+ application/json:
+ schema:
+ properties:
+ uuid: { type: string, example: nc0k04gk8g0cgsk440g0koko }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - Databases
+ summary: 'Update Env'
+ description: 'Update env by database UUID.'
+ operationId: update-env-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Env updated.'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - key
+ - value
+ properties:
+ key:
+ type: string
+ description: 'The key of the environment variable.'
+ value:
+ type: string
+ description: 'The value of the environment variable.'
+ is_literal:
+ type: boolean
+ description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
+ is_multiline:
+ type: boolean
+ description: 'The flag to indicate if the environment variable is multiline.'
+ is_shown_once:
+ type: boolean
+ description: "The flag to indicate if the environment variable's value is shown on the UI."
+ type: object
+ responses:
+ '201':
+ description: 'Environment variable updated.'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvironmentVariable'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/databases/{uuid}/envs/bulk':
+ patch:
+ tags:
+ - Databases
+ summary: 'Update Envs (Bulk)'
+ description: 'Update multiple envs by database UUID.'
+ operationId: update-envs-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Bulk envs updated.'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - data
+ properties:
+ data:
+ type: array
+ items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
+ type: object
+ responses:
+ '201':
+ description: 'Environment variables updated.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/EnvironmentVariable'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/databases/{uuid}/envs/{env_uuid}':
+ delete:
+ tags:
+ - Databases
+ summary: 'Delete Env'
+ description: 'Delete env by UUID.'
+ operationId: delete-env-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ -
+ name: env_uuid
+ in: path
+ description: 'UUID of the environment variable.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Environment variable deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Environment variable deleted.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
/deployments:
get:
tags:
@@ -6152,6 +6490,10 @@ paths:
type: boolean
default: false
description: 'Force domain override even if conflicts are detected.'
+ is_container_label_escape_enabled:
+ type: boolean
+ default: true
+ description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
type: object
responses:
'201':
@@ -6337,6 +6679,10 @@ paths:
type: boolean
default: false
description: 'Force domain override even if conflicts are detected.'
+ is_container_label_escape_enabled:
+ type: boolean
+ default: true
+ description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
type: object
responses:
'200':
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 7fbe25374..7564f625e 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.468"
+ "version": "4.0.0-beta.469"
},
"nightly": {
- "version": "4.0.0-beta.469"
+ "version": "4.0.0"
},
"helper": {
"version": "1.0.12"
diff --git a/public/svgs/imgcompress.png b/public/svgs/imgcompress.png
new file mode 100644
index 000000000..9eb04c3a7
Binary files /dev/null and b/public/svgs/imgcompress.png differ
diff --git a/public/svgs/librespeed.png b/public/svgs/librespeed.png
new file mode 100644
index 000000000..1405e3c18
Binary files /dev/null and b/public/svgs/librespeed.png differ
diff --git a/resources/css/app.css b/resources/css/app.css
index eeba1ee01..3cfa03dae 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -163,7 +163,7 @@ tbody {
}
tr {
- @apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200;
+ @apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100;
}
tr th {
diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php
index 135cad3a7..300a8d6e2 100644
--- a/resources/views/components/resources/breadcrumbs.blade.php
+++ b/resources/views/components/resources/breadcrumbs.blade.php
@@ -12,17 +12,18 @@
$projects = $projects ?? Project::ownedByCurrentTeamCached();
$environments = $environments ?? $resource->environment->project
->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
->with([
- 'applications',
- 'services',
- 'postgresqls',
- 'redis',
- 'mongodbs',
- 'mysqls',
- 'mariadbs',
- 'keydbs',
- 'dragonflies',
- 'clickhouses',
+ 'applications:id,uuid,name,environment_id',
+ 'services:id,uuid,name,environment_id',
+ 'postgresqls:id,uuid,name,environment_id',
+ 'redis:id,uuid,name,environment_id',
+ 'mongodbs:id,uuid,name,environment_id',
+ 'mysqls:id,uuid,name,environment_id',
+ 'mariadbs:id,uuid,name,environment_id',
+ 'keydbs:id,uuid,name,environment_id',
+ 'dragonflies:id,uuid,name,environment_id',
+ 'clickhouses:id,uuid,name,environment_id',
])
->get();
$currentProjectUuid = data_get($resource, 'environment.project.uuid');
@@ -63,7 +64,7 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
-
The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago, diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 4be11f51a..4e53cd80e 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -62,7 +62,7 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">