Merge remote-tracking branch 'origin/next' into ghe-support-helpers

This commit is contained in:
Andras Bacsai committed 2026-07-03 10:15:29 +02:00
commit b90380f542
477 files changed
+19669 -4970

No files matched your search

@@ -0,0 +1,16 @@
<?php
namespace App\Actions\Destination;
use App\Models\StandaloneDocker;
class RemoveStandaloneDockerNetwork
{
public function handle(StandaloneDocker $destination): void
{
$safeNetwork = escapeshellarg($destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class CleanupDocker
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
+1 -1
View File
@@ -26,7 +26,7 @@ class StartSentinel
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}
+5 -1
View File
@@ -118,10 +118,14 @@ class UpdateCoolify
{
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl();
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
'bash /data/coolify/source/upgrade.sh '.
escapeshellarg($this->latestVersion).' '.
escapeshellarg($latestHelperImageVersion).' '.
escapeshellarg($registryUrl),
], $this->server);
}
}
+1
View File
@@ -18,6 +18,7 @@ use Exception;
use Illuminate\Console\Command;
use Illuminate\Mail\Message;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Str;
use Mail;
use function Laravel\Prompts\confirm;
@@ -30,7 +30,6 @@ use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
class ApplicationsController extends Controller
{
@@ -59,6 +58,10 @@ class ApplicationsController extends Controller
]);
}
if ($application->is_shown_once ?? false) {
$application->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($application);
}
@@ -949,6 +952,10 @@ class ApplicationsController extends Controller
}
$serverUuid = $request->server_uuid;
$fqdn = $request->domains;
if ($request->has('domains') && is_string($request->domains)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $fqdn);
}
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
@@ -1028,7 +1035,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1193,7 +1200,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1236,7 +1243,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@@ -1432,7 +1439,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1476,7 +1483,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
@@ -1641,7 +1648,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1687,7 +1694,7 @@ class ApplicationsController extends Controller
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', 'dockerfile-'.new Cuid2);
$request->offsetSet('name', 'dockerfile-'.new_public_id());
}
$return = $this->validateDataApplications($request, $server);
@@ -1761,7 +1768,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1805,7 +1812,7 @@ class ApplicationsController extends Controller
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', 'docker-image-'.new Cuid2);
$request->offsetSet('name', 'docker-image-'.new_public_id());
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -1880,7 +1887,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -2375,7 +2382,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@@ -2479,29 +2486,7 @@ class ApplicationsController extends Controller
$requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
@@ -2509,6 +2494,9 @@ class ApplicationsController extends Controller
'errors' => $errors,
], 422);
}
$domains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $domains);
$urls = collect(ValidationPatterns::applicationDomainList($domains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -2678,7 +2666,7 @@ class ApplicationsController extends Controller
]);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -2873,8 +2861,12 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3097,12 +3089,18 @@ class ApplicationsController extends Controller
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
$item = collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
if ($item->has('key')) {
$item->put('key', ValidationPatterns::normalizeEnvironmentVariableKey((string) $item->get('key')));
}
return $item;
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3299,8 +3297,12 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3585,7 +3587,7 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -3783,7 +3785,7 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -3854,36 +3856,16 @@ class ApplicationsController extends Controller
}
if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $normalizedDomains);
$urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -4262,10 +4244,11 @@ class ApplicationsController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -4289,7 +4272,7 @@ class ApplicationsController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -4320,6 +4303,14 @@ class ApplicationsController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -4342,12 +4333,50 @@ class ApplicationsController extends Controller
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -177,6 +177,7 @@ class CloudProviderTokensController extends Controller
if (is_null($token)) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return response()->json($this->removeSensitiveData($token));
}
@@ -243,6 +244,7 @@ class CloudProviderTokensController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [CloudProviderToken::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -394,6 +396,7 @@ class CloudProviderTokensController extends Controller
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('update', $token);
$token->update(array_intersect_key($body, array_flip($allowedFields)));
@@ -475,6 +478,7 @@ class CloudProviderTokensController extends Controller
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('delete', $token);
if ($token->hasServers()) {
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
@@ -545,9 +549,18 @@ class CloudProviderTokensController extends Controller
if (! $cloudToken) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$this->authorize('view', $cloudToken);
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
auditLog('api.cloud_token.validated', [
'team_id' => $teamId,
'cloud_token_uuid' => $cloudToken->uuid,
'cloud_token_name' => $cloudToken->name,
'provider' => $cloudToken->provider,
'valid' => $validation['valid'],
]);
return response()->json([
'valid' => $validation['valid'],
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
@@ -3133,8 +3133,12 @@ class DatabasesController extends Controller
$this->authorize('manageEnvironment', $database);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3281,8 +3285,12 @@ class DatabasesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3399,8 +3407,12 @@ class DatabasesController extends Controller
$this->authorize('manageEnvironment', $database);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3684,10 +3696,11 @@ class DatabasesController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -3711,7 +3724,7 @@ class DatabasesController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -3742,6 +3755,14 @@ class DatabasesController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -3764,12 +3785,50 @@ class DatabasesController extends Controller
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -15,7 +15,6 @@ use App\Models\Tag;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
class DeployController extends Controller
{
@@ -511,7 +510,7 @@ class DeployController extends Controller
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $resource,
deployment_uuid: $deployment_uuid,
@@ -0,0 +1,239 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DestinationsController extends Controller
{
private function transform(StandaloneDocker|SwarmDocker $destination): array
{
return [
'uuid' => $destination->uuid,
'name' => $destination->name,
'network' => $destination->network,
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
'server_uuid' => $destination->server?->uuid,
'created_at' => $destination->created_at,
'updated_at' => $destination->updated_at,
];
}
/**
* Resolve the calling token's team id, or return an invalid-token response.
*/
private function teamIdOrAbort(): int|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return $teamId;
}
/**
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
* controller works on Coolify versions that pre-date that scope being added
* to the destination models (e.g. 4.0.0-beta.470).
*/
private function teamScopedDockers(int $teamId): array
{
return [
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
];
}
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
{
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$sets = $this->teamScopedDockers($teamId);
return response()->json(
$sets['standalone']->concat($sets['swarm'])
->map(fn ($destination) => $this->transform($destination))
->values()
);
}
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
->whereTeamId($teamId)
->whereUuid($server_uuid)
->firstOrFail();
$list = $server->standaloneDockers->concat($server->swarmDockers);
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
return response()->json($this->transform($destination));
}
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
$allowed = ['name', 'network', 'type'];
$validator = customApiValidator($request->all(), [
'name' => 'nullable|string|max:255',
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
'type' => 'nullable|in:standalone,swarm',
]);
$extra = array_diff(array_keys($request->all()), $allowed);
if ($validator->fails() || ! empty($extra)) {
$errors = $validator->errors();
if (! empty($extra)) {
foreach ($extra as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
$type = $request->input('type', $expectedType);
if ($type !== $expectedType) {
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
}
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
$this->authorize('create', $class);
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
if ($exists) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
try {
$destination = $class::create([
'name' => $name,
'network' => $request->input('network'),
'server_id' => $server->id,
]);
} catch (QueryException $exception) {
if ($this->isUniqueConstraintViolation($exception)) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
throw $exception;
}
auditLog('api.destination.created', [
'team_id' => $teamId,
'destination_uuid' => $destination->uuid,
'destination_name' => $destination->name,
'destination_type' => $type,
'server_uuid' => $server->uuid,
]);
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
}
private function isUniqueConstraintViolation(QueryException $exception): bool
{
$sqlState = $exception->errorInfo[0] ?? null;
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
return in_array($sqlState, ['23000', '23505'], true)
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
$this->authorize('delete', $destination);
// Guard against deleting destinations with attached resources. attachedTo()
// is recent on the destination models; fall back to a manual check for
// older Coolify versions (e.g. 4.0.0-beta.470).
if (method_exists($destination, 'attachedTo')) {
if ($destination->attachedTo()) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
} else {
$hasAttached = $destination->applications()->exists()
|| $destination->postgresqls()->exists()
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|| (method_exists($destination, 'services') && $destination->services()->exists());
if ($hasAttached) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
}
if ($destination instanceof StandaloneDocker) {
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
}
$destinationUuid = $destination->uuid;
$destinationName = $destination->name;
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
$serverUuid = $destination->server?->uuid;
$destination->delete();
auditLog('api.destination.deleted', [
'team_id' => $teamId,
'destination_uuid' => $destinationUuid,
'destination_name' => $destinationName,
'destination_type' => $destinationType,
'server_uuid' => $serverUuid,
]);
return response()->json(['message' => 'Deleted.']);
}
}
@@ -183,6 +183,7 @@ class GithubController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [GithubApp::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
@@ -568,6 +569,7 @@ class GithubController extends Controller
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$this->authorize('update', $githubApp);
// Define allowed fields for update
$allowedFields = [
@@ -752,6 +754,7 @@ class GithubController extends Controller
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$this->authorize('delete', $githubApp);
// Check if the GitHub app is being used by any applications
if ($githubApp->applications->isNotEmpty()) {
@@ -116,6 +116,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -237,6 +238,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -336,6 +338,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -445,6 +448,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -550,6 +554,7 @@ class HetznerController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [Server::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -620,6 +625,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
// Validate private key
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
@@ -97,6 +97,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('view', $project);
$project->load(['environments']);
@@ -233,6 +234,7 @@ class ProjectController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [Project::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -385,6 +387,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('update', $project);
$project->update($request->only($allowedFields));
@@ -469,6 +472,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('delete', $project);
if (! $project->isEmpty()) {
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
@@ -652,6 +656,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('update', $project);
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
if ($existingEnvironment) {
@@ -746,6 +751,7 @@ class ProjectController extends Controller
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
$this->authorize('delete', $environment);
if (! $environment->isEmpty()) {
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
@@ -110,6 +110,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
$this->authorize('view', $key);
return response()->json($this->removeSensitiveData($key));
}
@@ -176,6 +177,7 @@ class SecurityController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [PrivateKey::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
@@ -338,6 +340,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
$this->authorize('update', $foundKey);
$foundKey->update($request->only($allowedFields));
auditLog('api.private_key.updated', [
@@ -421,6 +424,7 @@ class SecurityController extends Controller
if (is_null($key)) {
return response()->json(['message' => 'Private Key not found.'], 404);
}
$this->authorize('delete', $key);
if ($key->isInUse()) {
return response()->json([
@@ -97,12 +97,12 @@ class SentinelController extends Controller
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
}
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
}
return response()->json(['message' => 'ok'], 200);
}
@@ -148,6 +148,7 @@ class ServersController extends Controller
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
if ($with_resources) {
$server['resources'] = $server->definedResources()->map(function ($resource) {
$payload = [
@@ -477,6 +478,7 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [ModelsServer::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -701,6 +703,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if ($request->proxy_type) {
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower();
@@ -825,6 +828,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('delete', $server);
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
@@ -924,6 +928,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
ValidateServer::dispatch($server);
auditLog('api.server.validated', [
+80 -28
View File
@@ -39,6 +39,10 @@ class ServicesController extends Controller
]);
}
if ($service->is_shown_once ?? false) {
$service->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($service);
}
@@ -56,19 +60,10 @@ class ServicesController extends Controller
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
$urls = collect(ValidationPatterns::applicationDomainList(
ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
));
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
@@ -97,10 +92,10 @@ class ServicesController extends Controller
}
if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
$containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
$containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
if (isset($result['error'])) {
$errors[] = $result['error'];
@@ -112,8 +107,6 @@ class ServicesController extends Controller
return;
}
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else {
$containerUrls = null;
}
@@ -1247,8 +1240,12 @@ class ServicesController extends Controller
$this->authorize('manageEnvironment', $service);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -1396,8 +1393,12 @@ class ServicesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -1515,8 +1516,12 @@ class ServicesController extends Controller
$this->authorize('manageEnvironment', $service);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -2099,10 +2104,11 @@ class ServicesController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -2134,7 +2140,7 @@ class ServicesController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2165,6 +2171,14 @@ class ServicesController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -2187,12 +2201,50 @@ class ServicesController extends Controller
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -110,6 +110,7 @@ class TeamController extends Controller
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$this->authorize('view', $team);
$team = $this->removeSensitiveData($team);
return response()->json(
@@ -168,6 +169,7 @@ class TeamController extends Controller
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$this->authorize('view', $team);
$members = $team->members;
$members->makeHidden([
'pivot',
+17 -4
View File
@@ -98,7 +98,7 @@ class Controller extends BaseController
public function link()
{
$token = request()->get('token');
if ($token) {
if (is_string($token) && $token !== '') {
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
@@ -126,9 +126,8 @@ class Controller extends BaseController
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
@@ -139,10 +138,11 @@ class Controller extends BaseController
}
$invitation->delete();
Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
@@ -152,6 +152,19 @@ class Controller extends BaseController
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
{
$query = parse_url($invitation->link, PHP_URL_QUERY);
if (! is_string($query)) {
return false;
}
parse_str($query, $parameters);
$storedToken = $parameters['token'] ?? null;
return is_string($storedToken) && hash_equals($storedToken, $token);
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');
+9 -40
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Controller as BaseController;
@@ -11,26 +13,11 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class UploadController extends BaseController
{
use AuthorizesRequests;
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
private const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
public function upload(Request $request)
{
@@ -40,6 +27,8 @@ class UploadController extends BaseController
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
$this->authorize('uploadBackup', $resource);
$chunk = $request->file('file');
$originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
@@ -80,10 +69,7 @@ class UploadController extends BaseController
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
@unlink($file->getPathname());
return response()->json([
@@ -103,24 +89,7 @@ class UploadController extends BaseController
private static function hasAllowedExtension(string $name): bool
{
$lower = strtolower($name);
$suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem !== '' && ! str_ends_with($stem, '.')) {
return true;
}
return false;
}
return false;
return DatabaseBackupFileValidator::hasAllowedExtension($name);
}
private static function formatMaxSize(): string
+2 -3
View File
@@ -10,7 +10,6 @@ use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
@@ -141,7 +140,7 @@ class Bitbucket extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -192,7 +191,7 @@ class Bitbucket extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
+2 -3
View File
@@ -11,7 +11,6 @@ use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
@@ -127,7 +126,7 @@ class Gitea extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -194,7 +193,7 @@ class Gitea extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
+12 -3
View File
@@ -17,7 +17,6 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Github extends Controller
{
@@ -144,7 +143,7 @@ class Github extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -262,6 +261,16 @@ class Github extends Controller
return response('Nothing to do. No GitHub App found.');
}
$webhook_secret = data_get($github_app, 'webhook_secret');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
@@ -362,7 +371,7 @@ class Github extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
+2 -3
View File
@@ -11,7 +11,6 @@ use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
@@ -168,7 +167,7 @@ class Gitlab extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -236,7 +235,7 @@ class Gitlab extends Controller
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
+2
View File
@@ -12,6 +12,7 @@ use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTeamMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
@@ -110,5 +111,6 @@ class Kernel extends HttpKernel
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
];
}
+25
View File
@@ -7,9 +7,34 @@ use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
{
/**
* Permissions that only admins/owners may use.
*/
private const MEMBER_DISALLOWED_ABILITIES = [
'root',
'write',
'write:sensitive',
'deploy',
'read:sensitive',
];
public function handle($request, $next, ...$abilities)
{
try {
$token = $request->user()->currentAccessToken();
$teamId = data_get($token, 'team_id');
if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) {
$tokenAbilities = $token->abilities ?? [];
$disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES);
if (! empty($disallowed)) {
return response()->json([
'message' => 'This API token has permissions ('.implode(', ', $disallowed).') that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
], 403);
}
}
if ($request->user()->tokenCan('root')) {
return $next($request);
}
+5 -2
View File
@@ -10,10 +10,13 @@ class ApiSensitiveData
public function handle(Request $request, Closure $next)
{
$token = $request->user()->currentAccessToken();
$hasTokenPermission = $token->can('root') || $token->can('read:sensitive');
$teamId = (int) data_get($token, 'team_id');
$isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false;
// Allow access to sensitive data if token has root or read:sensitive permission
// Allow access to sensitive data only if token has permission AND user is admin/owner
$request->attributes->add([
'can_read_sensitive' => $token->can('root') || $token->can('read:sensitive'),
'can_read_sensitive' => $hasTokenPermission && $isAdmin,
]);
return $next($request);
+5 -6
View File
@@ -12,15 +12,14 @@ class CanCreateResources
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
// if (! Gate::allows('createAnyResource')) {
// abort(403, 'You do not have permission to create resources.');
// }
if (! Gate::allows('createAnyResource')) {
abort(403, 'You do not have permission to create resources.');
}
// return $next($request);
return $next($request);
}
}
+49 -40
View File
@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -23,53 +24,61 @@ use Symfony\Component\HttpFoundation\Response;
class CanUpdateResource
{
/**
* @var array<string, list<class-string>>
*/
private const ROUTE_RESOURCE_MODELS = [
'application_uuid' => [Application::class],
'database_uuid' => [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
StandaloneMongodb::class,
],
'stack_service_uuid' => [ServiceApplication::class, ServiceDatabase::class],
'service_uuid' => [Service::class],
'server_uuid' => [Server::class],
'environment_uuid' => [Environment::class],
'project_uuid' => [Project::class],
];
public function handle(Request $request, Closure $next): Response
{
$resource = $this->resourceFromRoute($request);
if (! $resource) {
abort(404, 'Resource not found.');
}
if (! Gate::allows('update', $resource)) {
abort(403, 'You do not have permission to update this resource.');
}
return $next($request);
}
// Get resource from route parameters
// $resource = null;
// if ($request->route('application_uuid')) {
// $resource = Application::where('uuid', $request->route('application_uuid'))->first();
// } elseif ($request->route('service_uuid')) {
// $resource = Service::where('uuid', $request->route('service_uuid'))->first();
// } elseif ($request->route('stack_service_uuid')) {
// // Handle ServiceApplication or ServiceDatabase
// $stack_service_uuid = $request->route('stack_service_uuid');
// $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ??
// ServiceDatabase::where('uuid', $stack_service_uuid)->first();
// } elseif ($request->route('database_uuid')) {
// // Try different database types
// $database_uuid = $request->route('database_uuid');
// $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ??
// StandaloneMysql::where('uuid', $database_uuid)->first() ??
// StandaloneMariadb::where('uuid', $database_uuid)->first() ??
// StandaloneRedis::where('uuid', $database_uuid)->first() ??
// StandaloneKeydb::where('uuid', $database_uuid)->first() ??
// StandaloneDragonfly::where('uuid', $database_uuid)->first() ??
// StandaloneClickhouse::where('uuid', $database_uuid)->first() ??
// StandaloneMongodb::where('uuid', $database_uuid)->first();
// } elseif ($request->route('server_uuid')) {
// // For server routes, check if user can manage servers
// if (! auth()->user()->isAdmin()) {
// abort(403, 'You do not have permission to access this resource.');
// }
private function resourceFromRoute(Request $request): ?object
{
foreach (self::ROUTE_RESOURCE_MODELS as $routeParameter => $models) {
$uuid = $request->route($routeParameter);
// return $next($request);
// } elseif ($request->route('environment_uuid')) {
// $resource = Environment::where('uuid', $request->route('environment_uuid'))->first();
// } elseif ($request->route('project_uuid')) {
// $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first();
// }
if (! $uuid) {
continue;
}
// if (! $resource) {
// abort(404, 'Resource not found.');
// }
foreach ($models as $model) {
$resource = $model::where('uuid', $uuid)->first();
// if (! Gate::allows('update', $resource)) {
// abort(403, 'You do not have permission to update this resource.');
// }
if ($resource) {
return $resource;
}
}
}
// return $next($request);
return null;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTeamMcpEnabled
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $user?->currentAccessToken()?->team_id;
$team = $user?->teams()
->where('teams.id', $teamId)
->first();
if (! $team?->is_mcp_server_enabled) {
return response()->json(['message' => 'MCP server is disabled for this team.'], 403);
}
return $next($request);
}
}
+148 -24
View File
@@ -37,7 +37,6 @@ use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use Visus\Cuid2\Cuid2;
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -53,6 +52,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
'BUILDX_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_BUILDKIT',
'DOCKER_CERT_PATH',
'DOCKER_CLI_EXPERIMENTAL',
'DOCKER_CONFIG',
'DOCKER_CONTEXT',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
];
public $tries = 1;
public $timeout = 3600;
@@ -1032,7 +1046,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
}
foreach ($this->application->fileStorages as $fileStorage) {
if (! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
$fileStorage->saveStorageOnServer();
}
}
@@ -1702,6 +1716,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1753,6 +1771,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1834,8 +1856,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
]
);
}
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
@@ -2125,7 +2147,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
@@ -2207,7 +2229,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
queue_application_deployment(
deployment_uuid: $deployment_uuid,
application: $this->application,
@@ -2230,7 +2252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
$this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
}
if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn;
@@ -2242,17 +2264,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} ";
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} ";
$this->coolify_variables .= "COOLIFY_FQDN={$url} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
}
if (isset($this->application->git_branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
$this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
}
private function shellAssignmentForDockerfileArg(string $assignment): string
{
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
if ($value === null) {
return $assignment;
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
$value = str_replace("'\\''", "'", $value);
}
return "{$key}={$value}";
}
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
@@ -2307,6 +2345,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
[
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
'hidden' => true,
'skip_command_log' => true,
],
[
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
@@ -2326,7 +2366,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
);
}
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
// Note: Git warnings can be on the same line as the result (no newline)
@@ -2358,6 +2398,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function shouldResolveBranchHeadCommit(): bool
{
$commit = trim($this->commit);
return $commit === '' || $commit === 'HEAD';
}
private function clone_repository()
{
$importCommands = $this->generate_git_import_commands();
@@ -2366,12 +2413,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id !== 0) {
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
}
$this->execute_remote_command(
[
$importCommands,
'hidden' => true,
]
);
$this->execute_remote_command(...$this->gitCommandDefinitions($importCommands));
$this->create_workdir();
$this->execute_remote_command(
[
@@ -2401,6 +2443,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $commands;
}
private function gitCommandDefinitions(Collection|array|string $commands): array
{
if (is_string($commands)) {
return [
[
$commands,
'hidden' => true,
],
];
}
return collect($commands)
->map(function ($command): array {
if (is_string($command)) {
return [
$command,
'hidden' => true,
];
}
if (is_array($command)) {
return $command + ['hidden' => true];
}
return [
'command' => $command,
'hidden' => true,
];
})
->values()
->all();
}
private function cleanup_git()
{
$this->execute_remote_command(
@@ -2537,6 +2612,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
private function is_reserved_docker_client_env_key(?string $key): bool
{
if (blank($key)) {
return false;
}
return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true);
}
private function without_reserved_docker_client_variables(Collection $variables): Collection
{
return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key));
}
private function generate_railpack_env_variables(): Collection
{
$variables = $this->railpack_build_variables();
@@ -2657,6 +2746,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function railpack_build_command(string $imageName, Collection $variables): string
{
$variables = $this->without_reserved_docker_client_variables($variables);
$cacheArgs = '';
if ($this->force_rebuild) {
$cacheArgs = '--no-cache';
@@ -2668,12 +2759,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
}
$environmentPrefix = $this->railpack_build_environment_prefix($variables);
// Build-time variables reach the build through the sourced build-time .env file
// (written by save_buildtime_environment_variables), which interpolates shell-style
// references such as BETTER_AUTH_URL=$COOLIFY_URL. Passing them inline via `env`
// would forward the literal `$COOLIFY_URL` because each value is single-quoted and
// `env` does not interpolate its own assignments. Only buildpack control variables
// (NIXPACKS_/RAILPACK_) — which are excluded from the build-time .env file and never
// need interpolation — are still passed inline.
$controlVariables = $variables->filter(
fn ($value, $key) => str($key)->startsWith(EnvironmentVariable::BUILDPACK_CONTROL_VARIABLE_PREFIXES)
);
$environmentPrefix = $this->railpack_build_environment_prefix($controlVariables);
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
$buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
@@ -2683,6 +2784,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
.' --load'
." -t {$imageName}"
." {$this->workdir}";
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
}
private function decode_railpack_config(string $config, string $source): array
@@ -2836,9 +2940,25 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
}
private function ensure_helper_docker_buildx_available_for_railpack(): void
{
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
'hidden' => true,
'save' => 'railpack_helper_buildx_available',
]);
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
return;
}
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
}
private function build_railpack_image(): void
{
$this->ensure_docker_buildx_available_for_railpack();
$this->ensure_helper_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file();
@@ -4038,6 +4158,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$variables = $this->env_args;
if ($this->build_pack === 'railpack') {
$variables = $this->without_reserved_docker_client_variables($variables);
}
if ($variables->isEmpty()) {
return '';
}
@@ -4178,7 +4302,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
@@ -4200,7 +4324,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
+1 -1
View File
@@ -36,7 +36,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
+11 -5
View File
@@ -16,6 +16,7 @@ use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -27,7 +28,6 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -309,7 +309,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
$this->backup_log_uuid = (string) new Cuid2;
$this->backup_log_uuid = new_public_id();
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
@@ -715,9 +715,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$escapedBackupLocation = escapeshellarg($this->backup_location);
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
@@ -733,7 +739,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function getFullImageName(): string
{
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
+1 -2
View File
@@ -15,7 +15,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use Visus\Cuid2\Cuid2;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
@@ -166,7 +165,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
}
// Queue the deployment
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
queue_application_deployment(
application: $application,
pull_request_id: $this->pullRequestId,
+29 -1
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Notifications\Dto\DiscordMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -41,6 +44,31 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
Http::post($this->webhookUrl, $this->message->toPayload());
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
}
}
+40 -6
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Notifications\Dto\SlackMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -34,8 +37,33 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
if ($this->isSlackWebhook()) {
$this->sendToSlack();
$this->sendToSlack($httpOptions);
return;
}
@@ -45,7 +73,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
*
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/
$this->sendToMattermost();
$this->sendToMattermost($httpOptions);
}
private function isSlackWebhook(): bool
@@ -62,9 +90,12 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
return $scheme === 'https' && $host === 'hooks.slack.com';
}
private function sendToSlack(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToSlack(array $httpOptions): void
{
Http::post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'text' => $this->message->title,
'blocks' => [
[
@@ -102,11 +133,14 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
/**
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
*/
private function sendToMattermost(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToMattermost(array $httpOptions): void
{
$username = config('app.name');
Http::post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'username' => $username,
'attachments' => [
[
+13 -2
View File
@@ -50,7 +50,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
if ($validator->fails()) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
@@ -64,7 +64,18 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
]);
}
$response = Http::post($this->webhookUrl, $this->payload);
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
$response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
+4 -1
View File
@@ -260,7 +260,10 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$quantity = max(
UpdateSubscriptionQuantity::MIN_SERVER_LIMIT,
min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT)
);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
+3
View File
@@ -54,6 +54,9 @@ class Index extends Component
public function getSubscribers()
{
if (Auth::id() !== 0 && ! session('impersonating')) {
return redirect()->route('dashboard');
}
$this->inactiveSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', false)->count();
$this->activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->count();
}
+14 -2
View File
@@ -9,13 +9,15 @@ use App\Models\Server;
use App\Models\Team;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Index extends Component
{
use AuthorizesRequests;
protected $listeners = [
'refreshBoardingIndex' => 'validateServer',
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
@@ -174,6 +176,9 @@ class Index extends Component
public function skipBoarding()
{
if (auth()->user()?->isMember()) {
return redirect()->route('dashboard');
}
Team::find(currentTeam()->id)->update([
'show_boarding' => false,
]);
@@ -276,6 +281,7 @@ class Index extends Component
]);
try {
$this->authorize('create', PrivateKey::class);
$privateKey = PrivateKey::createAndStore([
'name' => $this->privateKeyName,
'description' => $this->privateKeyDescription,
@@ -294,6 +300,12 @@ class Index extends Component
{
$this->validate();
try {
$this->authorize('create', Server::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
if ($foundServer) {
@@ -457,7 +469,7 @@ class Index extends Component
$this->createdProject = Project::create([
'name' => 'My first project',
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
$this->currentState = 'create-resource';
}
+2 -3
View File
@@ -9,7 +9,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Docker extends Component
{
@@ -35,7 +34,7 @@ class Docker extends Component
public function mount(?string $server_id = null): void
{
$this->network = (string) new Cuid2;
$this->network = new_public_id();
$this->servers = Server::isUsable()->get();
if (filled($server_id)) {
@@ -68,7 +67,7 @@ class Docker extends Component
public function generateName(): void
{
$name = data_get($this->selectedServer, 'name', new Cuid2);
$name = data_get($this->selectedServer, 'name', new_public_id());
$this->name = str("{$name}-{$this->network}")->kebab();
}
+5
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Destination;
use App\Models\StandaloneDocker;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -31,8 +32,12 @@ class Show extends Component
if (! $destination) {
return redirect()->route('destination.index');
}
$this->authorize('view', $destination);
$this->destination = $destination;
$this->syncData();
} catch (AuthorizationException) {
abort(403);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1 -2
View File
@@ -4,7 +4,6 @@ namespace App\Livewire;
// use Livewire\Component;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class MonacoEditor extends Component
{
@@ -40,7 +39,7 @@ class MonacoEditor extends Component
public function render()
{
if (is_null($this->id)) {
$this->id = new Cuid2;
$this->id = new_public_id();
}
if (is_null($this->name)) {
+32 -20
View File
@@ -2,12 +2,16 @@
namespace App\Livewire;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class NavbarDeleteTeam extends Component
{
use AuthorizesRequests;
public $team;
public function mount()
@@ -17,27 +21,35 @@ class NavbarDeleteTeam extends Component
public function delete($password, $selectedActions = [])
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
try {
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$currentTeam = currentTeam();
$this->authorize('delete', $currentTeam);
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
$newTeam = Auth::user()->teams()->first();
refreshSession($newTeam);
return redirect()->route('team.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
$currentTeam = currentTeam();
$currentTeam->delete();
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
refreshSession();
return redirectRoute($this, 'team.index');
}
public function render()
+3 -1
View File
@@ -110,7 +110,9 @@ class Discord extends Component
refreshSession();
} else {
$this->discordEnabled = $this->settings->discord_enabled;
$this->discordWebhookUrl = $this->settings->discord_webhook_url;
$this->discordWebhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->discord_webhook_url
: null;
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
+10 -2
View File
@@ -170,11 +170,15 @@ class Email extends Component
$this->smtpPort = $this->settings->smtp_port;
$this->smtpEncryption = $this->settings->smtp_encryption;
$this->smtpUsername = $this->settings->smtp_username;
$this->smtpPassword = $this->settings->smtp_password;
$this->smtpPassword = auth()->user()->can('update', $this->settings)
? $this->settings->smtp_password
: null;
$this->smtpTimeout = $this->settings->smtp_timeout;
$this->resendEnabled = $this->settings->resend_enabled;
$this->resendApiKey = $this->settings->resend_api_key;
$this->resendApiKey = auth()->user()->can('update', $this->settings)
? $this->settings->resend_api_key
: null;
$this->useInstanceEmailSettings = $this->settings->use_instance_email_settings;
@@ -242,6 +246,8 @@ class Email extends Component
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
@@ -289,6 +295,8 @@ class Email extends Component
public function submitResend()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
+7 -2
View File
@@ -113,8 +113,13 @@ class Pushover extends Component
refreshSession();
} else {
$this->pushoverEnabled = $this->settings->pushover_enabled;
$this->pushoverUserKey = $this->settings->pushover_user_key;
$this->pushoverApiToken = $this->settings->pushover_api_token;
if (auth()->user()->can('update', $this->settings)) {
$this->pushoverUserKey = $this->settings->pushover_user_key;
$this->pushoverApiToken = $this->settings->pushover_api_token;
} else {
$this->pushoverUserKey = null;
$this->pushoverApiToken = null;
}
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
+3 -1
View File
@@ -110,7 +110,9 @@ class Slack extends Component
refreshSession();
} else {
$this->slackEnabled = $this->settings->slack_enabled;
$this->slackWebhookUrl = $this->settings->slack_webhook_url;
$this->slackWebhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->slack_webhook_url
: null;
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
+7 -2
View File
@@ -169,8 +169,13 @@ class Telegram extends Component
$this->settings->save();
} else {
$this->telegramEnabled = $this->settings->telegram_enabled;
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
if (auth()->user()->can('update', $this->settings)) {
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
} else {
$this->telegramToken = null;
$this->telegramChatId = null;
}
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
+3 -1
View File
@@ -105,7 +105,9 @@ class Webhook extends Component
refreshSession();
} else {
$this->webhookEnabled = $this->settings->webhook_enabled;
$this->webhookUrl = $this->settings->webhook_url;
$this->webhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->webhook_url
: null;
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
+5 -2
View File
@@ -4,11 +4,13 @@ namespace App\Livewire\Project;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class AddEmpty extends Component
{
use AuthorizesRequests;
public string $name;
public string $description = '';
@@ -29,12 +31,13 @@ class AddEmpty extends Component
public function submit()
{
try {
$this->authorize('create', Project::class);
$this->validate();
$project = Project::create([
'name' => $this->name,
'description' => $this->description,
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
$productionEnvironment = $project->environments()->where('name', 'production')->first();
@@ -59,7 +59,9 @@ class Show extends Component
$this->application_deployment_queue = $application_deployment_queue;
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->deployment_uuid = $deploymentUuid;
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->is_debug_enabled = auth()->user()->isMember()
? false
: $this->application->settings->is_debug_enabled;
$this->isKeepAliveOn();
}
@@ -110,6 +112,8 @@ class Show extends Component
public function downloadAllLogs(): string
{
$this->authorize('update', $this->application);
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) {
$prefix = '';
@@ -6,11 +6,14 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Livewire\Component;
class DeploymentNavbar extends Component
{
use AuthorizesRequests;
public ApplicationDeploymentQueue $application_deployment_queue;
public Application $application;
@@ -25,7 +28,9 @@ class DeploymentNavbar extends Component
{
$this->application = Application::ownedByCurrentTeam()->find($this->application_deployment_queue->application_id);
$this->server = $this->application->destination->server;
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->is_debug_enabled = auth()->user()->isMember()
? false
: $this->application->settings->is_debug_enabled;
}
public function deploymentFinished()
@@ -35,15 +40,21 @@ class DeploymentNavbar extends Component
public function show_debug()
{
$this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
$this->application->settings->save();
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->dispatch('refreshQueue');
try {
$this->authorize('update', $this->application);
$this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
$this->application->settings->save();
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->dispatch('refreshQueue');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function force_start()
{
try {
$this->authorize('deploy', $this->application);
force_start_deployment($this->application_deployment_queue);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -58,10 +69,15 @@ class DeploymentNavbar extends Component
return '';
}
$isMember = auth()->user()->isMember();
$markdown = "# Deployment Logs\n\n";
$markdown .= "```\n";
foreach ($logs as $log) {
if ($isMember && ! empty($log['hidden'])) {
continue;
}
if (isset($log['output'])) {
$markdown .= $log['output']."\n";
}
@@ -74,6 +90,11 @@ class DeploymentNavbar extends Component
public function cancel()
{
try {
$this->authorize('deploy', $this->application);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
+7 -14
View File
@@ -12,8 +12,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class General extends Component
{
@@ -143,7 +141,8 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@@ -549,7 +548,7 @@ class General extends Component
try {
$this->authorize('update', $this->application);
$uuid = new Cuid2;
$uuid = new_public_id();
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
@@ -772,16 +771,7 @@ class General extends Component
$oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
@@ -864,6 +854,9 @@ class General extends Component
}
}
if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
+92 -77
View File
@@ -7,7 +7,6 @@ use App\Actions\Docker\GetContainersStatus;
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Heading extends Component
{
@@ -65,107 +64,123 @@ class Heading extends Component
public function force_deploy_without_cache()
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->deploy(force_rebuild: true);
$this->deploy(force_rebuild: true);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deploy(bool $force_rebuild = false)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
return;
return;
}
if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.');
return;
}
if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/build-server">documentation</a>');
return;
}
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.');
return;
}
if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/build-server">documentation</a>');
return;
}
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
}
protected function setDeploymentUuid()
{
$this->deploymentUuid = new Cuid2;
$this->deploymentUuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deploymentUuid;
}
public function stop()
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function restart()
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
}
public function render()
+28 -20
View File
@@ -6,10 +6,10 @@ use App\Actions\Docker\GetContainersStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Previews extends Component
{
@@ -118,12 +118,14 @@ class Previews extends Component
});
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
@@ -234,31 +236,38 @@ class Previews extends Component
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$dockerRegistryImageTag = null;
if ($this->application->build_pack === 'dockerimage') {
$dockerRegistryImageTag = $this->application->previews()
->where('pull_request_id', $pull_request_id)
->value('docker_registry_image_tag');
$dockerRegistryImageTag = null;
if ($this->application->build_pack === 'dockerimage') {
$dockerRegistryImageTag = $this->application->previews()
->where('pull_request_id', $pull_request_id)
->value('docker_registry_image_tag');
}
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
}
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
@@ -305,7 +314,7 @@ class Previews extends Component
protected function setDeploymentUuid()
{
$this->deployment_uuid = new Cuid2;
$this->deployment_uuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}
@@ -350,9 +359,8 @@ class Previews extends Component
public function stop(int $pull_request_id)
{
$this->authorize('deploy', $this->application);
try {
$this->authorize('deploy', $this->application);
$server = $this->application->destination->server;
if ($this->application->destination->server->isSwarm()) {
@@ -3,10 +3,10 @@
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class PreviewsCompose extends Component
{
@@ -34,6 +34,11 @@ class PreviewsCompose extends Component
{
try {
$this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@@ -64,7 +69,7 @@ class PreviewsCompose extends Component
if (empty($domain_string)) {
$server = $this->preview->application->destination->server;
$template = $this->preview->application->preview_url_template;
$random = new Cuid2;
$random = new_public_id();
// Generate a unique domain like main app services do
$generated_fqdn = generateUrl(server: $server, random: $random);
@@ -74,12 +79,16 @@ class PreviewsCompose extends Component
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application
// Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string);
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$random = new Cuid2;
$random = new_public_id();
foreach ($domain_list as $single_domain) {
$single_domain = trim($single_domain);
@@ -6,7 +6,6 @@ use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Rollback extends Component
{
@@ -52,7 +51,7 @@ class Rollback extends Component
$commit = validateGitRef($commit, 'rollback commit');
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $this->application,
@@ -3,11 +3,14 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Swarm extends Component
{
use AuthorizesRequests;
public Application $application;
#[Validate('required')]
@@ -51,6 +54,7 @@ class Swarm extends Component
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
@@ -61,6 +65,7 @@ class Swarm extends Component
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
+12 -9
View File
@@ -11,11 +11,13 @@ use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class CloneMe extends Component
{
use AuthorizesRequests;
public string $project_uuid;
public string $environment_uuid;
@@ -61,7 +63,7 @@ class CloneMe extends Component
->servers()
->get()
->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug();
$this->newName = str($this->project->name.'-clone-'.new_public_id())->slug();
}
public function toggleVolumeCloning(bool $value)
@@ -91,6 +93,7 @@ class CloneMe extends Component
public function clone(string $type)
{
try {
$this->authorize('create', Project::class);
$this->validate([
'selectedDestination' => 'required',
'newName' => ValidationPatterns::nameRules(),
@@ -108,7 +111,7 @@ class CloneMe extends Component
if ($this->environment->name !== 'production') {
$project->environments()->create([
'name' => $this->environment->name,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
}
$environment = $project->environments->where('name', $this->environment->name)->first();
@@ -120,7 +123,7 @@ class CloneMe extends Component
$project = $this->project;
$environment = $this->project->environments()->create([
'name' => $this->newName,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
}
$applications = $this->environment->applications;
@@ -134,7 +137,7 @@ class CloneMe extends Component
}
foreach ($databases as $database) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newDatabase = $database->replicate([
'id',
'created_at',
@@ -225,7 +228,7 @@ class CloneMe extends Component
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
@@ -254,7 +257,7 @@ class CloneMe extends Component
}
foreach ($services as $service) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newService = $service->replicate([
'id',
'created_at',
@@ -278,7 +281,7 @@ class CloneMe extends Component
'created_at',
'updated_at',
])->fill([
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
'service_id' => $newService->id,
'team_id' => currentTeam()->id,
]);
@@ -409,7 +412,7 @@ class CloneMe extends Component
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
+52 -6
View File
@@ -2,10 +2,13 @@
namespace App\Livewire\Project\Database;
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -17,7 +20,7 @@ class BackupEdit extends Component
public ScheduledDatabaseBackup $backup;
#[Locked]
public $s3s;
public $availableS3Storages;
#[Locked]
public $parameters;
@@ -68,7 +71,7 @@ class BackupEdit extends Component
public bool $disableLocalBackup = false;
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = 1;
public ?int $s3StorageId = null;
#[Validate(['nullable', 'string'])]
public ?string $databasesToBackup = null;
@@ -128,7 +131,7 @@ class BackupEdit extends Component
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
$this->saveS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
$this->s3StorageId = $this->backup->s3_storage_id;
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first();
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
@@ -190,6 +193,18 @@ class BackupEdit extends Component
}
}
public function backupNow()
{
try {
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
@@ -202,6 +217,11 @@ class BackupEdit extends Component
}
}
public function updatedS3StorageId(): void
{
$this->instantSave();
}
private function customValidate()
{
if (! is_numeric($this->backup->s3_storage_id)) {
@@ -209,10 +229,14 @@ class BackupEdit extends Component
}
// S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = collect($this->s3s)->pluck('id');
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->save_s3 = $this->saveS3 = false;
$availableS3Ids = $this->availableS3StorageIds();
if ($availableS3Ids->isEmpty()) {
$this->backup->s3_storage_id = $this->s3StorageId = null;
if ($this->backup->save_s3) {
$this->backup->save_s3 = $this->saveS3 = false;
}
} elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
}
// Validate that disable_local_backup can only be true when S3 backup is enabled
@@ -227,6 +251,28 @@ class BackupEdit extends Component
$this->validate();
}
private function availableS3StorageIds(): Collection
{
$storages = collect($this->availableS3Storages);
$storageIds = $storages->pluck('id')->filter()->all();
if (empty($storageIds)) {
return collect();
}
$teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all();
if (empty($teamIds)) {
return collect();
}
return S3Storage::query()
->whereKey($storageIds)
->whereIn('team_id', $teamIds)
->where('is_usable', true)
->pluck('id');
}
public function submit()
{
try {
@@ -3,12 +3,16 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class BackupExecutions extends Component
{
use AuthorizesRequests;
public ?ScheduledDatabaseBackup $backup = null;
public $database;
@@ -44,29 +48,45 @@ class BackupExecutions extends Component
public function cleanupFailed()
{
if ($this->backup) {
$this->backup->executions()->where('status', 'failed')->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', 'Failed backups cleaned up.');
try {
$this->authorize('manageBackups', $this->database);
if ($this->backup) {
$this->backup->executions()->where('status', 'failed')->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', 'Failed backups cleaned up.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function cleanupDeleted()
{
if ($this->backup) {
$deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
if ($deletedCount > 0) {
$this->backup->executions()->where('local_storage_deleted', true)->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
} else {
$this->dispatch('info', 'No backup entries found that are deleted from local storage.');
try {
$this->authorize('manageBackups', $this->database);
if ($this->backup) {
$deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
if ($deletedCount > 0) {
$this->backup->executions()->where('local_storage_deleted', true)->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
} else {
$this->dispatch('info', 'No backup entries found that are deleted from local storage.');
}
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteBackup($executionId, $password, $selectedActions = [])
{
try {
$this->authorize('manageBackups', $this->database);
} catch (\Throwable $e) {
return handleError($e, $this);
}
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
@@ -78,7 +98,7 @@ class BackupExecutions extends Component
return;
}
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class
? $execution->scheduledDatabaseBackup->database->service->destination->server
: $execution->scheduledDatabaseBackup->database->destination->server;
@@ -185,7 +205,7 @@ class BackupExecutions extends Component
if ($this->database) {
$server = null;
if ($this->database instanceof \App\Models\ServiceDatabase) {
if ($this->database instanceof ServiceDatabase) {
$server = $this->database->service->destination->server;
} elseif ($this->database->destination && $this->database->destination->server) {
$server = $this->database->destination->server;
+7 -3
View File
@@ -14,9 +14,13 @@ class BackupNow extends Component
public function backupNow()
{
$this->authorize('manageBackups', $this->backup->database);
try {
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
@@ -42,6 +42,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function getListeners(): array
{
$user = Auth::user();
@@ -72,6 +74,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->clickhouseAdminPassword = '';
}
}
protected function rules(): array
@@ -40,6 +40,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function getListeners(): array
{
$user = Auth::user();
@@ -70,6 +72,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->dragonflyPassword = '';
}
}
protected function rules(): array
+16 -6
View File
@@ -90,18 +90,28 @@ class Heading extends Component
public function restart()
{
$this->authorize('manage', $this->database);
try {
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$activity = RestartDatabase::run($this->database);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function start()
{
$this->authorize('manage', $this->database);
try {
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$activity = StartDatabase::run($this->database);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
+82 -5
View File
@@ -14,6 +14,8 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@@ -27,11 +29,10 @@ class ImportForm extends Component
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
return ValidationPatterns::isValidS3BucketName($bucket);
}
/**
@@ -451,10 +452,20 @@ EOD;
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
Storage::delete($backupFileName);
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
return true;
}
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
@@ -465,6 +476,7 @@ EOD;
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@@ -570,7 +582,7 @@ EOD;
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return;
}
@@ -587,6 +599,7 @@ EOD;
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
]);
// Check if file exists
@@ -651,7 +664,7 @@ EOD;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return true;
}
@@ -667,7 +680,7 @@ EOD;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
@@ -721,6 +734,7 @@ EOD;
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
@@ -765,6 +779,69 @@ EOD;
return true;
}
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
{
$script = $this->buildPostgresRestoreScanScript($tmpPath);
if ($script === null) {
return null;
}
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
}
/**
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
* backup contains directives leading to OS command execution.
*
* Hardened against bypasses:
* - decompresses gzip backups before scanning,
* - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
*/
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
{
if (! $this->isPostgresqlRestore()) {
return null;
}
$escapedTmpPath = escapeshellarg($tmpPath);
// Token separator PostgreSQL treats as whitespace: real whitespace or a
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$pattern = implode('|', [
"copy{$sep}+[^;]*(from|to){$sep}+program",
'(^|[[:space:]])\\\\!',
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
]);
$escapedPattern = escapeshellarg($pattern);
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
{
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
if ($command !== null) {
$commands[] = $command;
}
}
private function isPostgresqlRestore(): bool
{
$morphClass = $this->resource->getMorphClass();
if ($morphClass === ServiceDatabase::class) {
return str_contains($this->resource->databaseType(), 'postgres');
}
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
@@ -2,13 +2,20 @@
namespace App\Livewire\Project\Database;
use App\Models\StandalonePostgresql;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
class InitScript extends Component
{
use AuthorizesRequests;
#[Locked]
public StandalonePostgresql $database;
#[Locked]
public array $script;
@@ -35,6 +42,7 @@ class InitScript extends Component
public function submit()
{
try {
$this->authorize('update', $this->database);
$this->validate();
$this->script['index'] = $this->index;
$this->script['content'] = $this->content;
@@ -48,6 +56,7 @@ class InitScript extends Component
public function delete()
{
try {
$this->authorize('update', $this->database);
$this->dispatch('delete_init_script', $this->script);
} catch (Exception $e) {
return handleError($e, $this);
@@ -42,6 +42,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
public bool $isPasswordHiddenForMember = false;
public function getListeners(): array
{
$user = Auth::user();
@@ -72,6 +74,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->keydbPassword = '';
}
}
protected function rules(): array
@@ -47,6 +47,8 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $isPasswordHiddenForMember = false;
protected function rules(): array
{
return [
@@ -126,6 +128,12 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->mariadbRootPassword = '';
$this->mariadbPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -45,6 +45,8 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $isPasswordHiddenForMember = false;
protected function rules(): array
{
return [
@@ -119,6 +121,11 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->mongoInitdbRootPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -47,6 +47,8 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $isPasswordHiddenForMember = false;
protected function rules(): array
{
return [
@@ -126,6 +128,12 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->mysqlRootPassword = '';
$this->mysqlPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -55,6 +55,8 @@ class General extends Component
public string $new_content;
public bool $isPasswordHiddenForMember = false;
protected $listeners = [
'save_init_script',
'delete_init_script',
@@ -140,6 +142,11 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->postgresPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -45,6 +45,8 @@ class General extends Component
public string $redisVersion;
public bool $isPasswordHiddenForMember = false;
protected $listeners = [
'envsUpdated' => 'refresh',
];
@@ -118,6 +120,11 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->redisPassword = '';
}
}
public function syncData(bool $toModel = false)
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -34,7 +35,7 @@ class ScheduledBackups extends Component
$this->setSelectedBackup($this->selectedBackupId, true);
}
$this->parameters = get_route_parameters();
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->type = 'service-database';
} else {
$this->type = 'database';
@@ -56,22 +57,30 @@ class ScheduledBackups extends Component
public function setCustomType()
{
$this->authorize('update', $this->database);
try {
$this->authorize('update', $this->database);
$this->database->custom_type = $this->custom_type;
$this->database->save();
$this->dispatch('success', 'Database type set.');
$this->refreshScheduledBackups();
$this->database->custom_type = $this->custom_type;
$this->database->save();
$this->dispatch('success', 'Database type set.');
$this->refreshScheduledBackups();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function delete($scheduled_backup_id): void
{
$backup = $this->database->scheduledBackups->find($scheduled_backup_id);
$this->authorize('manageBackups', $this->database);
try {
$this->authorize('manageBackups', $this->database);
$backup->delete();
$this->dispatch('success', 'Scheduled backup deleted.');
$this->refreshScheduledBackups();
$backup = $this->database->scheduledBackups->find($scheduled_backup_id);
$backup->delete();
$this->dispatch('success', 'Scheduled backup deleted.');
$this->refreshScheduledBackups();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function refreshScheduledBackups(?int $id = null): void
+14 -10
View File
@@ -28,18 +28,22 @@ class DeleteEnvironment extends Component
public function delete()
{
$this->validate([
'environment_id' => 'required|int',
]);
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
try {
$this->validate([
'environment_id' => 'required|int',
]);
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
if ($environment->isEmpty()) {
$environment->delete();
if ($environment->isEmpty()) {
$environment->delete();
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
}
}
+14 -10
View File
@@ -26,18 +26,22 @@ class DeleteProject extends Component
public function delete()
{
$this->validate([
'project_id' => 'required|int',
]);
$project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
$this->authorize('delete', $project);
try {
$this->validate([
'project_id' => 'required|int',
]);
$project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
$this->authorize('delete', $project);
if ($project->isEmpty()) {
$project->delete();
if ($project->isEmpty()) {
$project->delete();
return redirectRoute($this, 'project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
}
}
+4
View File
@@ -4,10 +4,13 @@ namespace App\Livewire\Project;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Edit extends Component
{
use AuthorizesRequests;
public Project $project;
public string $name;
@@ -54,6 +57,7 @@ class Edit extends Component
public function submit()
{
try {
$this->authorize('update', $this->project);
$this->syncData(true);
$this->dispatch('success', 'Project updated.');
} catch (\Throwable $e) {
+4
View File
@@ -5,11 +5,14 @@ namespace App\Livewire\Project;
use App\Models\Application;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class EnvironmentEdit extends Component
{
use AuthorizesRequests;
public Project $project;
public Application $application;
@@ -62,6 +65,7 @@ class EnvironmentEdit extends Component
public function submit()
{
try {
$this->authorize('update', $this->environment);
$this->syncData(true);
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
@@ -5,11 +5,14 @@ namespace App\Livewire\Project\New;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
class DockerCompose extends Component
{
use AuthorizesRequests;
public string $dockerComposeRaw = '';
public string $envFile = '';
@@ -30,6 +33,8 @@ class DockerCompose extends Component
public function submit()
{
try {
$this->authorize('create', Service::class);
$this->validate([
'dockerComposeRaw' => 'required',
]);
+6 -2
View File
@@ -6,11 +6,13 @@ use App\Models\Application;
use App\Models\Project;
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class DockerImage extends Component
{
use AuthorizesRequests;
public string $imageName = '';
public string $imageTag = '';
@@ -81,6 +83,8 @@ class DockerImage extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ValidationPatterns::dockerImageTagRules(),
@@ -130,7 +134,7 @@ class DockerImage extends Component
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'name' => 'docker-image-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
+6 -2
View File
@@ -3,17 +3,21 @@
namespace App\Livewire\Project\New;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class EmptyProject extends Component
{
use AuthorizesRequests;
public function createEmptyProject()
{
$this->authorize('create', Project::class);
$project = Project::create([
'name' => generate_random_name(),
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
@@ -14,6 +15,8 @@ use Livewire\Component;
class GithubPrivateRepository extends Component
{
use AuthorizesRequests;
public $current_step = 'github_apps';
public $github_apps;
@@ -169,6 +172,8 @@ class GithubPrivateRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
// Validate git repository parts and branch
$validator = validator([
'selected_repository_owner' => $this->selected_repository_owner,
@@ -10,12 +10,15 @@ use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Livewire\Component;
use Spatie\Url\Url;
class GithubPrivateRepositoryDeployKey extends Component
{
use AuthorizesRequests;
public $current_step = 'private_keys';
public $parameters;
@@ -128,6 +131,8 @@ class GithubPrivateRepositoryDeployKey extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate();
try {
$destination_uuid = $this->query['destination'] ?? null;
@@ -6,16 +6,18 @@ use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
class PublicGitRepository extends Component
{
use AuthorizesRequests;
public string $repository_url;
public int $port = 3000;
@@ -260,6 +262,8 @@ class PublicGitRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
$this->validate();
// Additional validation for git repository and branch
@@ -295,33 +299,6 @@ class PublicGitRepository extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
$server = $destination->server;
$new_service = [
'name' => 'service'.str()->random(10),
'docker_compose_raw' => 'coolify',
'environment_id' => $environment->id,
'server_id' => $server->id,
];
if ($this->git_source === 'other') {
$new_service['git_repository'] = $this->git_repository;
$new_service['git_branch'] = $this->git_branch;
} else {
$new_service['git_repository'] = $this->git_repository;
$new_service['git_branch'] = $this->git_branch;
$new_service['source_id'] = $this->git_source->id;
$new_service['source_type'] = $this->git_source->getMorphClass();
}
$service = Service::create($new_service);
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,
]);
return;
}
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),
+9 -3
View File
@@ -112,14 +112,17 @@ class Select extends Component
$default_logo = 'images/default.webp';
$logo = data_get($service, 'logo', $default_logo);
$local_logo_path = public_path($logo);
$serviceKey = (string) $key;
return [
'name' => str($key)->headline(),
'id' => $serviceKey,
'name' => str($serviceKey)->headline(),
'docsSlug' => str($serviceKey)->lower()->value(),
'logo' => asset($logo),
'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null,
'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
] + (array) $service;
})->all();
@@ -336,7 +339,10 @@ class Select extends Component
public function setType(string $type)
{
$type = str($type)->lower()->slug()->value();
if (! str($type)->startsWith('one-click-service-')) {
$type = str($type)->lower()->slug()->value();
}
if ($this->loading) {
return;
}
@@ -5,11 +5,13 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class SimpleDockerfile extends Component
{
use AuthorizesRequests;
public string $dockerfile = '';
public array $parameters;
@@ -30,6 +32,8 @@ CMD ["nginx", "-g", "daemon off;"]
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'dockerfile' => 'required',
]);
@@ -48,7 +52,7 @@ CMD ["nginx", "-g", "daemon off;"]
$port = 80;
}
$application = Application::create([
'name' => 'dockerfile-'.new Cuid2,
'name' => 'dockerfile-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
+4
View File
@@ -4,16 +4,20 @@ namespace App\Livewire\Project\Resource;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
public $type;
public $project;
public function mount()
{
$this->authorize('createAnyResource');
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
+22 -9
View File
@@ -3,10 +3,13 @@
namespace App\Livewire\Project\Service;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class EditCompose extends Component
{
use AuthorizesRequests;
public Service $service;
public $serviceId;
@@ -72,19 +75,29 @@ class EditCompose extends Component
public function saveEditedCompose()
{
$this->dispatch('info', 'Saving new docker compose...');
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
try {
$this->authorize('update', $this->service);
$this->dispatch('info', 'Saving new docker compose...');
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
$this->validate([
'isContainerLabelEscapeEnabled' => 'required',
]);
$this->syncData(true);
$this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]);
$this->dispatch('success', 'Service updated successfully');
try {
$this->authorize('update', $this->service);
$this->validate([
'isContainerLabelEscapeEnabled' => 'required',
]);
$this->syncData(true);
$this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]);
$this->dispatch('success', 'Service updated successfully');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
+10 -13
View File
@@ -3,10 +3,10 @@
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
@@ -28,12 +28,15 @@ class EditDomain extends Component
public $requiredPort = null;
#[Validate(['nullable'])]
#[Validate]
public ?string $fqdn = null;
protected $rules = [
'fqdn' => 'nullable',
];
protected function rules(): array
{
return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
];
}
public function mount()
{
@@ -82,15 +85,9 @@ class EditDomain extends Component
{
try {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
$this->validate();
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+31 -3
View File
@@ -94,6 +94,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
$this->fileStorage->deleteStorageOnServer();
$this->fileStorage->is_directory = true;
$this->fileStorage->content = null;
@@ -110,8 +114,11 @@ class FileStorage extends Component
public function loadStorageOnServer()
{
try {
// Loading content is a read operation, so we use 'view' permission
$this->authorize('view', $this->resource);
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be loaded from the server.');
}
$this->fileStorage->loadStorageOnServer();
$this->syncData();
@@ -128,6 +135,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
$this->fileStorage->deleteStorageOnServer();
$this->fileStorage->is_directory = false;
$this->fileStorage->content = null;
@@ -155,8 +166,10 @@ class FileStorage extends Component
$message = 'File deleted.';
if ($this->fileStorage->is_directory) {
$message = 'Directory deleted.';
} elseif ($this->fileStorage->is_host_file) {
$message = 'Host file mount removed.';
}
if ($this->permanently_delete) {
if ($this->permanently_delete && ! $this->fileStorage->is_host_file) {
$message = 'Directory deleted from the server.';
$this->fileStorage->deleteStorageOnServer();
}
@@ -175,6 +188,12 @@ class FileStorage extends Component
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
return;
}
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
@@ -206,6 +225,12 @@ class FileStorage extends Component
public function instantSave(): void
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
return;
}
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
@@ -224,6 +249,9 @@ class FileStorage extends Component
'fileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
],
'hostFileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],
],
]);
}
}
+39 -28
View File
@@ -110,17 +110,20 @@ class Heading extends Component
public function start()
{
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id);
try {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function forceDeploy()
{
$this->authorizeService('deploy');
try {
$this->authorizeService('deploy');
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
$q->where('properties->status', ProcessStatus::IN_PROGRESS->value)
@@ -131,49 +134,57 @@ class Heading extends Component
$activity->save();
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function stop()
{
$this->authorizeService('stop');
try {
$this->authorizeService('stop');
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function restart()
{
$this->authorizeService('deploy');
try {
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
return;
return;
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id);
}
public function pullAndRestartEvent()
{
$this->authorizeService('deploy');
try {
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
return;
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id);
}
private function authorizeService(string $ability): void
+5 -9
View File
@@ -8,11 +8,11 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component
{
@@ -480,15 +480,11 @@ class Index extends Component
{
try {
$this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
$this->validate([
'fqdn' => ValidationPatterns::applicationDomainRules(),
]);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+25 -3
View File
@@ -4,16 +4,21 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class StackForm extends Component
{
use AuthorizesRequests;
public Service $service;
public Collection $fields;
public bool $isPasswordHiddenForMember = false;
protected $listeners = ['saveCompose'];
// Explicit properties
@@ -118,6 +123,17 @@ class StackForm extends Component
})->flatMap(function ($group) {
return $group;
});
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
if ($this->isPasswordHiddenForMember) {
$this->fields = $this->fields->map(function ($field) {
if (data_get($field, 'isPassword')) {
$field['value'] = null;
}
return $field;
});
}
}
public function saveCompose($raw)
@@ -128,14 +144,20 @@ class StackForm extends Component
public function instantSave()
{
$this->syncData(true);
$this->service->save();
$this->dispatch('success', 'Service settings saved.');
try {
$this->authorize('update', $this->service);
$this->syncData(true);
$this->service->save();
$this->dispatch('success', 'Service settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit($notify = true)
{
try {
$this->authorize('update', $this->service);
$this->validate();
$this->syncData(true);
+68 -12
View File
@@ -29,6 +29,10 @@ class Storage extends Component
public ?string $file_storage_content = null;
public string $host_file_storage_source = '';
public string $host_file_storage_destination = '';
public string $file_storage_directory_source = '';
public string $file_storage_directory_destination = '';
@@ -146,19 +150,9 @@ class Storage extends Component
'file_storage_content' => 'nullable|string',
]);
$this->file_storage_path = trim($this->file_storage_path);
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
$this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
// Validate path to prevent command injection
validateShellSafePath($this->file_storage_path, 'file storage path');
if ($this->resource->getMorphClass() === Application::class) {
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} else {
throw new \Exception('No valid resource type for file mount storage type!');
}
$fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
LocalFileVolume::create([
'fs_path' => $fs_path,
@@ -178,6 +172,38 @@ class Storage extends Component
}
}
public function submitHostFileStorage()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'host_file_storage_source' => 'required|string',
'host_file_storage_destination' => 'required|string',
]);
$this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path');
$this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path');
LocalFileVolume::create([
'fs_path' => $this->host_file_storage_source,
'mount_path' => $this->host_file_storage_destination,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->clearForm();
$this->refreshStorages();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorageDirectory()
{
try {
@@ -222,6 +248,8 @@ class Storage extends Component
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
$this->host_file_storage_source = '';
$this->host_file_storage_destination = '';
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
@@ -230,6 +258,34 @@ class Storage extends Component
}
}
public function fileStorageHostPath(): string
{
if (method_exists($this->resource, 'workdir')) {
return $this->resource->workdir();
}
if ($this->resource->getMorphClass() === Application::class) {
return application_configuration_dir().'/'.$this->resource->uuid;
}
if (str($this->resource->getMorphClass())->contains('Standalone')) {
return database_configuration_dir().'/'.$this->resource->uuid;
}
throw new \Exception('No valid resource type for file mount storage type!');
}
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
if ($path->isEmpty()) {
return $this->fileStorageHostPath().'/';
}
return $this->fileStorageHostPath().$path->start('/')->value();
}
public function render()
{
return view('livewire.project.service.storage');
+1 -2
View File
@@ -8,7 +8,6 @@ use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Danger extends Component
{
@@ -39,7 +38,7 @@ class Danger extends Component
public function mount()
{
$parameters = get_route_parameters();
$this->modalId = new Cuid2;
$this->modalId = new_public_id();
$this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentUuid = data_get($parameters, 'environment_uuid');
+9 -4
View File
@@ -7,12 +7,14 @@ use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Destination extends Component
{
use AuthorizesRequests;
public $resource;
public Collection $networks;
@@ -59,6 +61,7 @@ class Destination extends Component
public function stop($serverId)
{
try {
$this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server);
$this->refreshServers();
@@ -70,12 +73,13 @@ class Destination extends Component
public function redeploy(int $network_id, int $server_id)
{
try {
$this->authorize('deploy', $this->resource);
if ($this->resource->additional_servers->count() > 0 && str($this->resource->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
return;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail();
$result = queue_application_deployment(
@@ -128,7 +132,7 @@ class Destination extends Component
});
$this->resource->refresh();
$this->refreshServers();
} catch (\Exception $e) {
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
@@ -149,7 +153,7 @@ class Destination extends Component
$this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
$this->dispatch('refresh');
} catch (\Exception $e) {
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
@@ -157,6 +161,7 @@ class Destination extends Component
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
{
try {
$this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
@@ -93,6 +93,10 @@ class All extends Component
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
@@ -111,7 +115,7 @@ class All extends Component
$query->orderBy('order');
}
return $query->get();
return $this->nullLockedValues($query->get());
}
private function searchTerm(): string
@@ -119,12 +123,35 @@ class All extends Component
return trim($this->search);
}
private function supportsPreviewEnvironmentVariables(): bool
{
return $this->showPreview && $this->resource instanceof Application;
}
public function getHasEnvironmentVariablesProperty(): bool
{
return $this->environmentVariables->isNotEmpty() ||
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
$hasPreviewEnvironmentVariables;
}
private function nullLockedValues($envs)
{
$isMember = auth()->user()?->isMember();
$envs->each(function ($env) use ($isMember) {
if ($env->is_shown_once || $isMember) {
$env->value = null;
$env->real_value = null;
}
});
return $envs;
}
public function getIsSearchActiveProperty(): bool
@@ -144,6 +171,10 @@ class All extends Component
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect([]);
}
// Only for services and docker-compose applications
if ($this->resource->type() !== 'service' &&
($this->resourceClass !== 'App\Models\Application' ||
@@ -204,7 +235,12 @@ class All extends Component
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
$isMember = auth()->user()?->isMember();
return $variables->map(function ($item) use ($isMember) {
if ($isMember) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
@@ -61,6 +61,8 @@ class Show extends Component
public bool $is_redis_credential = false;
public bool $isValueHidden = false;
public array $problematicVariables = [];
protected $listeners = [
@@ -160,6 +162,13 @@ class Show extends Component
$this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false;
$this->real_value = $this->env->real_value;
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
}
@@ -6,12 +6,15 @@ use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Component;
class ExecuteContainerCommand extends Component
{
use AuthorizesRequests;
public $selected_container = 'default';
public Collection $containers;
@@ -40,6 +43,7 @@ class ExecuteContainerCommand extends Component
if (data_get($this->parameters, 'application_uuid')) {
$this->type = 'application';
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
$this->authorize('view', $this->resource);
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
@@ -56,6 +60,7 @@ class ExecuteContainerCommand extends Component
abort(404);
}
$this->resource = $resource;
$this->authorize('view', $this->resource);
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
@@ -63,6 +68,7 @@ class ExecuteContainerCommand extends Component
} elseif (data_get($this->parameters, 'service_uuid')) {
$this->type = 'service';
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->resource);
if ($this->resource->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->server);
}
@@ -70,6 +76,7 @@ class ExecuteContainerCommand extends Component
} elseif (data_get($this->parameters, 'server_uuid')) {
$this->type = 'server';
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
$this->authorize('view', $this->resource);
$this->servers = $this->servers->push($this->resource);
}
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());
@@ -152,7 +159,9 @@ class ExecuteContainerCommand extends Component
public function connectToServer()
{
try {
$this->authorize('canAccessTerminal');
$server = $this->servers->first();
$this->authorize('view', $server);
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}
@@ -181,6 +190,7 @@ class ExecuteContainerCommand extends Component
return;
}
try {
$this->authorize('canAccessTerminal');
// Validate container name format
if (! ValidationPatterns::isValidContainerName($this->selected_container)) {
throw new \InvalidArgumentException('Invalid container name format');
@@ -198,6 +208,8 @@ class ExecuteContainerCommand extends Component
throw new \RuntimeException('Invalid server configuration.');
}
$this->authorize('view', $server);
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}
Loaded 100 of 477 files, more files were not shown because too many files have changed in this diff. Show more