diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml
index 594724fdb..45a695ddc 100644
--- a/.github/workflows/pr-quality.yaml
+++ b/.github/workflows/pr-quality.yaml
@@ -40,7 +40,10 @@ jobs:
max-emoji-count: 2
max-code-references: 5
require-linked-issue: false
- blocked-terms: "STRAWBERRY"
+ blocked-terms: |
+ STRAWBERRY
+ ๐ค Generated with Claude Code
+ Generated with Claude Code
blocked-issue-numbers: 8154
# PR Template Checks
@@ -97,7 +100,7 @@ jobs:
exempt-pr-milestones: ""
# PR Success Actions
- success-add-pr-labels: "quality/verified"
+ success-add-pr-labels: ""
# PR Failure Actions
failure-remove-pr-labels: ""
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87e8ae806..8cd7287f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1190,7 +1190,118 @@ All notable changes to this project will be documented in this file.
- *(service)* Update autobase to version 2.5 (#7923)
- *(service)* Add chibisafe template (#5808)
- *(ui)* Improve sidebar menu items styling (#7928)
-- *(service)* Improve open-archiver
+- *(template)* Add open archiver template (#6593)
+- *(service)* Add linkding template (#6651)
+- *(service)* Add glip template (#7937)
+- *(templates)* Add Sessy docker compose template (#7951)
+- *(api)* Add update urls support to services api
+- *(api)* Improve service urls update
+- *(api)* Add url update support to services api (#7929)
+- *(api)* Improve docker_compose_domains
+- *(api)* Add more allowed fields
+- *(notifications)* Add mattermost notifications (#7963)
+- *(templates)* Add ElectricSQL docker compose template
+- *(service)* Add back soketi-app-manager
+- *(service)* Upgrade checkmate to v3 (#7995)
+- *(service)* Update pterodactyl version (#7981)
+- *(service)* Add langflow template (#8006)
+- *(service)* Upgrade listmonk to v6
+- *(service)* Add alexandrie template (#8021)
+- *(service)* Upgrade formbricks to v4 (#8022)
+- *(service)* Add goatcounter template (#8029)
+- *(installer)* Add tencentos as a supported os
+- *(installer)* Update nightly install script
+- Update pr template to remove unnecessary quote blocks
+- *(service)* Add satisfactory game server (#8056)
+- *(service)* Disable mautic (#8088)
+- *(service)* Add bento-pdf (#8095)
+- *(ui)* Add official postgres 18 support
+- *(database)* Add official postgres 18 support
+- *(ui)* Use 2 column layout
+- *(database)* Add official postgres 18 and pgvector 18 support (#8143)
+- *(ui)* Improve global search with uuid and pr support (#7901)
+- *(openclaw)* Add Openclaw service with environment variables and health checks
+- *(service)* Disable maybe
+- *(service)* Disable maybe (#8167)
+- *(service)* Add sure
+- *(service)* Add sure (#8157)
+- *(docker)* Install PHP sockets extension in development environment
+- *(services)* Add Spacebot service with custom logo support (#8427)
+- Expose scheduled tasks to API
+- *(api)* Add OpenAPI for managing scheduled tasks for applications and services
+- *(api)* Add delete endpoints for scheduled tasks in applications and services
+- *(api)* Add update endpoints for scheduled tasks in applications and services
+- *(api)* Add scheduled tasks CRUD API with auth and validation (#8428)
+- *(monitoring)* Add scheduled job monitoring dashboard (#8433)
+- *(service)* Disable plane
+- *(service)* Disable plane (#8580)
+- *(service)* Disable pterodactyl panel and pterodactyl wings
+- *(service)* Disable pterodactyl panel and pterodactyl wings (#8512)
+- *(service)* Upgrade beszel and beszel-agent to v0.18
+- *(service)* Upgrade beszel and beszel-agent to v0.18 (#8513)
+- Add command healthcheck type
+- Require health check command for 'cmd' type with backend validation and frontend update
+- *(healthchecks)* Add command health checks with input validation
+- *(healthcheck)* Add command-based health check support (#8612)
+- *(jobs)* Optimize async job dispatches and enhance Stripe subscription sync
+- *(jobs)* Add queue delay resilience to scheduled job execution
+- *(scheduler)* Add pagination to skipped jobs and filter manager start events
+- Add comment field to environment variables
+- Limit comment field to 256 characters for environment variables
+- Enhance environment variable handling to support mixed formats and add comprehensive tests
+- Add comment field to shared environment variables
+- Show comment field for locked environment variables
+- Add function to extract inline comments from docker-compose YAML environment variables
+- Add magic variable detection and update UI behavior accordingly
+- Add comprehensive environment variable parsing with nested resolution and hardcoded variable detection
+- *(models)* Add is_required to EnvironmentVariable fillable array
+- Add comment field to environment variables (#7269)
+- *(service)* Pydio-cells.yml
+- Pydio cells svg
+- Pydio-cells.yml pin to stable version
+- *(service)* Add Pydio cells (#8323)
+- *(service)* Disable minio community edition
+- *(service)* Disable minio community edition (#8686)
+- *(subscription)* Add Stripe server limit quantity adjustment flow
+- *(subscription)* Add refunds and cancellation management (#8637)
+- Add configurable timeout for public database TCP proxy
+- Add configurable proxy timeout for public database TCP proxy (#8673)
+- *(jobs)* Implement encrypted queue jobs
+- *(proxy)* Add database-backed config storage with disk backups
+- *(proxy)* Add database-backed config storage with disk backups (#8905)
+- *(livewire)* Add selectedActions parameter and error handling to delete methods
+- *(gitlab)* Add GitLab source integration with SSH and HTTP basic auth
+- *(git-sources)* Add GitLab integration and URL encode credentials (#8910)
+- *(server)* Add server metadata collection and display
+- *(git-import)* Support custom ssh command for fetch, submodule, and lfs
+- *(ui)* Add log filter based on log level
+- *(ui)* Add log filter based on log level (#8784)
+- *(seeders)* Add GitHub deploy key example application
+- *(service)* Update n8n-with-postgres-and-worker to 2.10.4 (#8807)
+- *(service)* Add container label escape control to services API
+- *(server)* Allow force deletion of servers with resources
+- *(server)* Allow force deletion of servers with resources (#8962)
+- *(compose-preview)* Populate fqdn from docker_compose_domains
+- *(compose-preview)* Populate fqdn from docker_compose_domains (#8963)
+- *(server)* Auto-fetch server metadata after validation
+- *(server)* Auto-fetch server metadata after validation (#8964)
+- *(templates)* Add imgcompress service, for offline image processing (#8763)
+- *(service)* Add librespeed (#8626)
+- *(service)* Update databasus to v3.16.2 (#8586)
+- *(preview)* Add configurable PR suffix toggle for volumes
+- *(api)* Add storages endpoints for applications
+- *(api)* Expand update_storage to support name, mount_path, host_path, content fields
+- *(environment-variable)* Add placeholder hint for magic variables
+- *(subscription)* Display next billing date and billing interval
+- *(api)* Support comments in bulk environment variable endpoints
+- *(api)* Add database environment variable management endpoints
+- *(storage)* Add resources tab and improve S3 deletion handling
+- *(storage)* Group backups by database and filter by s3 status
+- *(storage)* Add storage management for backup schedules
+- *(jobs)* Add cache-based deduplication for delayed cron execution
+- *(storage)* Add storage endpoints and UUID support for databases and services
+- *(monitoring)* Add Laravel Nightwatch monitoring support
+- *(validation)* Make hostname validation case-insensitive and expand allowed characters
### ๐ Bug Fixes
@@ -3773,6 +3884,7 @@ All notable changes to this project will be documented in this file.
- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6
- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy
+- *(git)* Tracking issue due to case sensitivity
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7
- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency
- *(horizon)* Update queue configuration to use environment variable for dynamic queue management
@@ -3798,7 +3910,6 @@ All notable changes to this project will be documented in this file.
- *(application)* Add option to suppress toast notifications when loading compose file
- *(git)* Tracking issue due to case sensitivity
- *(git)* Tracking issue due to case sensitivity
-- *(git)* Tracking issue due to case sensitivity
- *(ui)* Delete button width on small screens (#6308)
- *(service)* Matrix entrypoint
- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307)
@@ -4422,6 +4533,197 @@ All notable changes to this project will be documented in this file.
- *(api)* Deprecate applications compose endpoint
- *(api)* Applications post and patch endpoints
- *(api)* Applications create and patch endpoints (#7917)
+- *(service)* Sftpgo port
+- *(env)* Only cat .env file in dev
+- *(api)* Encoding checks (#7944)
+- *(env)* Only show nixpacks plan variables section in dev
+- Switch custom labels check to UTF-8
+- *(api)* One click service name and description cannot be set during creation
+- *(ui)* Improve volume mount warning for compose applications (#7947)
+- *(api)* Show an error if the same 2 urls are provided
+- *(preview)* Docker compose preview URLs (#7959)
+- *(api)* Check domain conflicts within the request
+- *(api)* Include docker_compose_domains in domain conflict check
+- *(api)* Is_static and docker network missing
+- *(api)* If domains field is empty clear the fqdn column
+- *(api)* Application endpoint issues part 2 (#7948)
+- Optimize queries and caching for projects and environments
+- *(perf)* Eliminate N+1 queries from InstanceSettings and Server lookups (#7966)
+- Update version numbers to 4.0.0-beta.462 and 4.0.0-beta.463
+- *(service)* Update seaweedfs logo (#7971)
+- *(service)* Soju svg
+- *(service)* Autobase database is not persisted correctly (#7978)
+- *(ui)* Make tooltips a bit wider
+- *(ui)* Modal issues
+- *(validation)* Add @, / and & support to names and descriptions
+- *(backup)* Postgres restore arithmetic syntax error (#7997)
+- *(service)* Users unable to create their first ente account without SMTP (#7986)
+- *(ui)* Horizontal overflow on application and service headings (#7970)
+- *(service)* Supabase studio settings redirect loop (#7828)
+- *(env)* Skip escaping for valid JSON in environment variables (#6160)
+- *(service)* Disable kong response buffering and increase timeouts (#7864)
+- *(service)* Rocketchat fails to start due to database version incompatibility (#7999)
+- *(service)* N8n v2 with worker timeout error
+- *(service)* Elasticsearch-with-kibana not generating account token
+- *(service)* Elasticsearch-with-kibana not generating account token (#8067)
+- *(service)* Kimai fails to start (#8027)
+- *(service)* Reactive-resume template (#8048)
+- *(api)* Infinite loop with github app with many repos (#8052)
+- *(env)* Skip escaping for valid JSON in environment variables (#8080)
+- *(docker)* Update PostgreSQL version to 16 in Dockerfile
+- *(validation)* Enforce url validation for instance domain (#8078)
+- *(service)* Bluesky pds invite code doesn't generate (#8081)
+- *(service)* Bugsink login fails due to cors (#8083)
+- *(service)* Strapi doesn't start (#8084)
+- *(service)* Activepieces postgres 18 volume mount (#8098)
+- *(service)* Forgejo login failure (#8145)
+- *(database)* Pgvector 18 version is not parsed properly
+- *(labels)* Make sure name is slugified
+- *(parser)* Replace dashes and dots in auto generated envs
+- Stop database proxy when is_public changes to false (#8138)
+- *(docs)* Update documentation link for Openclaw service
+- *(api-docs)* Use proper schema references for environment variable endpoints (#8239)
+- *(ui)* Fix datalist border color and add repository selection watcher (#8240)
+- *(server)* Improve IP uniqueness validation with team-specific error messages
+- *(jobs)* Initialize status variable in checkHetznerStatus (#8359)
+- *(jobs)* Handle queue timeouts gracefully in Horizon (#8360)
+- *(push-server-job)* Skip containers with empty service subId (#8361)
+- *(database)* Disable proxy on port allocation failure (#8362)
+- *(sentry)* Use withScope for SSH retry event tracking (#8363)
+- *(api)* Add a newline to openapi.json
+- *(server)* Improve IP uniqueness validation with team-specific error messages
+- *(service)* Glitchtip webdashboard doesn't load
+- *(service)* Glitchtip webdashboard doesn't load (#8249)
+- *(api)* Improve scheduled tasks API with auth, validation, and execution endpoints
+- *(api)* Improve scheduled tasks validation and delete logic
+- *(security)* Harden deployment paths and deploy abilities (#8549)
+- *(service)* Always enable force https labels
+- *(traefik)* Respect force https in service labels (#8550)
+- *(team)* Include webhook notifications in enabled check (#8557)
+- *(service)* Resolve team lookup via service relationship
+- *(service)* Resolve team lookup via service relationship (#8559)
+- *(database)* Chown redis/keydb configs when custom conf set (#8561)
+- *(version)* Update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465
+- *(applications)* Treat zero private_key_id as deploy key (#8563)
+- *(deploy)* Split BuildKit and secrets detection (#8565)
+- *(auth)* Prevent CSRF redirect loop during 2FA challenge (#8596)
+- *(input)* Prevent eye icon flash on password fields before Alpine.js loads (#8599)
+- *(api)* Correct permission requirements for POST endpoints (#8600)
+- *(health-checks)* Prevent command injection in health check commands (#8611)
+- *(auth)* Prevent cross-tenant IDOR in resource cloning (#8613)
+- *(docker)* Centralize command escaping in executeInDocker helper (#8615)
+- *(api)* Add team authorization to domains_by_server endpoint (#8616)
+- *(ca-cert)* Prevent command injection via base64 encoding (#8617)
+- *(scheduler)* Add self-healing for stale Redis locks and detection in UI (#8618)
+- *(health-checks)* Sanitize and validate CMD healthcheck commands
+- *(healthchecks)* Remove redundant newline sanitization from CMD healthcheck
+- *(soketi)* Make host binding configurable for IPv6 support (#8619)
+- *(ssh)* Automatically fix SSH directory permissions during upgrade (#8635)
+- *(jobs)* Prevent non-due jobs firing on restart and enrich skip logs with resource links
+- *(database)* Close confirmation modal after import/restore
+- Application rollback uses correct commit sha
+- *(rollback)* Escape commit SHA to prevent shell injection
+- Save comment field when creating application environment variables
+- Allow editing comments on locked environment variables
+- Add Update button for locked environment variable comments
+- Remove duplicate delete button from locked environment variable view
+- Position Update button next to comment field for locked variables
+- Preserve existing comments in bulk update and always show save notification
+- Update success message logic to only show when changes are made
+- *(bootstrap)* Add bounds check to extractBalancedBraceContent
+- Pydio-cells svg path typo
+- *(database)* Handle PDO constant name change for PGSQL_ATTR_DISABLE_PREPARES
+- *(proxy)* Handle IPv6 CIDR notation in Docker network gateways (#8703)
+- *(ssh)* Prevent RCE via SSH command injection (#8748)
+- *(service)* Cloudreve doesn't persist data across restarts
+- *(service)* Cloudreve doesn't persist data across restarts (#8740)
+- Join link should be set correctly in the env variables
+- *(service)* Ente photos join link doesn't work (#8727)
+- *(subscription)* Harden quantity updates and proxy trust behavior
+- *(auth)* Resolve 419 session errors with domain-based access and Cloudflare Tunnels (#8749)
+- *(server)* Handle limit edge case and IPv6 allowlist dedupe
+- *(server-limit)* Re-enable force-disabled servers at limit
+- *(ip-allowlist)* Add IPv6 CIDR support for API access restrictions (#8750)
+- *(proxy)* Remove ipv6 cidr network remediation
+- Address review feedback on proxy timeout
+- *(proxy)* Add validation and normalization for database proxy timeout
+- *(proxy)* Mounting error for nginx.conf in dev
+- Enable preview deployment page for deploy key applications
+- *(application-source)* Support localhost key with id=0
+- Enable preview deployment page for deploy key applications (#8579)
+- *(docker-compose)* Respect preserveRepository setting when executing start command (#8848)
+- *(proxy)* Mounting error for nginx.conf in dev (#8662)
+- *(database)* Close confirmation modal after database import/restore (#8697)
+- *(subscription)* Use optional chaining for preview object access
+- *(parser)* Use firstOrCreate instead of updateOrCreate for environment variables
+- *(env-parser)* Capture clean variable names without trailing braces in bash-style defaults (#8855)
+- *(terminal)* Resolve WebSocket connection and host authorization issues (#8862)
+- *(docker-cleanup)* Respect keep for rollback setting for Nixpacks build images (#8859)
+- *(push-server)* Track last_online_at and reset database restart state
+- *(docker)* Prevent false container exits on failed docker queries (#8860)
+- *(api)* Require write permission for validation endpoints
+- *(sentinel)* Add token validation to prevent command injection
+- *(log-drain)* Prevent command injection by base64-encoding environment variables
+- *(git-ref-validation)* Prevent command injection via git references
+- *(docker)* Add path validation to prevent command injection in file locations
+- Prevent command injection and fix developer view shared variables error (#8889)
+- Build-time environment variables break Next.js (#8890)
+- *(modal)* Make confirmation modal close after dispatching Livewire actions (#8892)
+- *(parser)* Preserve user-saved env vars on Docker Compose redeploy (#8894)
+- *(security)* Sanitize newlines in health check commands to prevent RCE (#8898)
+- Prevent scheduled task input fields from losing focus
+- Prevent scheduled task input fields from losing focus (#8654)
+- *(api)* Add docker_cleanup parameter to stop endpoints
+- *(api)* Add docker_cleanup parameter to stop endpoints (#8899)
+- *(deployment)* Filter null and empty environment variables from nixpacks plan
+- *(deployment)* Filter null and empty environment variables from nixpacks plan (#8902)
+- *(livewire)* Add error handling and selectedActions to delete methods (#8909)
+- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables
+- *(parsers)* Use firstOrCreate instead of updateOrCreate for environment variables (#8915)
+- *(ssh)* Remove undefined trackSshRetryEvent() method call (#8927)
+- *(validation)* Support scoped packages in file path validation (#8928)
+- *(parsers)* Resolve shared variables in compose environment
+- *(parsers)* Resolve shared variables in compose environment (#8930)
+- *(api)* Cast teamId to int in deployment authorization check
+- *(api)* Cast teamId to int in deployment authorization check (#8931)
+- *(git-import)* Ensure ssh key is used for fetch, submodule, and lfs operations (#8933)
+- *(ui)* Info logs were not highlighted with blue color
+- *(application)* Clarify deployment type precedence logic
+- *(git-import)* Explicitly specify ssh key and remove duplicate validation rules
+- *(application)* Clarify deployment type precedence logic (#8934)
+- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain
+- *(git)* GitHub App webhook endpoint defaults to IPv4 instead of the instance domain (#8948)
+- *(service)* Hoppscotch fails to start due to db unhealthy
+- *(service)* Hoppscotch fails to start due to db unhealthy (#8949)
+- *(api)* Allow is_container_label_escape_enabled in service operations (#8955)
+- *(docker-compose)* Respect preserveRepository when injecting --project-directory
+- *(docker-compose)* Respect preserveRepository when injecting --project-directory (#8956)
+- *(compose)* Include git branch in compose file not found error
+- *(template)* Fix heyform template
+- *(template)* Fix heyform template (#8747)
+- *(preview)* Exclude bind mounts from preview deployment suffix
+- *(preview)* Sync isPreviewSuffixEnabled property on file storage save
+- *(storages)* Hide PR suffix for services and fix instantSave logic
+- *(preview)* Enable per-volume control of PR suffix in preview deployments (#9006)
+- Prevent sporadic SSH permission denied by validating key content
+- *(ssh)* Handle chmod failures gracefully and simplify key management
+- Prevent sporadic SSH permission denied on key rotation (#8990)
+- *(stripe)* Add error handling and resilience to subscription operations
+- *(stripe)* Add error handling and resilience to subscription operations (#9030)
+- *(api)* Extract resource UUIDs from route parameters
+- *(backup)* Throw explicit error when S3 storage missing or deleted (#9038)
+- *(docker)* Skip cleanup stale warning on cloud instances
+- *(deployment)* Disable build server during restart operations
+- *(deployment)* Disable build server during restart operations (#9045)
+- *(docker)* Log failed cleanup attempts when server is not functional
+- *(environment-variable)* Guard refresh against missing or stale variables
+- *(github-webhook)* Handle unsupported event types gracefully
+- *(github-webhook)* Handle unsupported event types gracefully (#9119)
+- *(deployment)* Properly escape shell arguments in nixpacks commands
+- *(deployment)* Properly escape shell arguments in nixpacks commands (#9122)
+- *(validation)* Make hostname validation case-insensitive and expand allowed name characters (#9134)
+- *(team)* Resolve server limit checks for API token authentication (#9123)
+- *(subscription)* Prevent duplicate subscriptions with updateOrCreate
### ๐ผ Other
@@ -4886,6 +5188,12 @@ All notable changes to this project will be documented in this file.
- CVE-2025-55182 React2shell infected supabase/studio:2025.06.02-sha-8f2993d
- Bump superset to 6.0.0
- Trim whitespace from domain input in instance settings (#7837)
+- Upgrade postgres client to fix build error
+- Application rollback uses correct commit sha (#8576)
+- *(deps)* Bump rollup from 4.57.1 to 4.59.0
+- *(deps)* Bump rollup from 4.57.1 to 4.59.0 (#8691)
+- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1
+- *(deps)* Bump league/commonmark from 2.8.0 to 2.8.1 (#8793)
### ๐ Refactor
@@ -5510,6 +5818,23 @@ All notable changes to this project will be documented in this file.
- Move all env sorting to one place
- *(api)* Make docker_compose_raw description more clear
- *(api)* Update application create endpoints docs
+- *(api)* Application urls validation
+- *(services)* Improve some service slogans
+- *(ssh-retry)* Remove Sentry tracking from retry logic
+- *(ssh-retry)* Remove Sentry tracking from retry logic
+- *(jobs)* Split task skip checks into critical and runtime phases
+- Add explicit fillable array to EnvironmentVariable model
+- Replace inline note with callout component for consistency
+- *(application-source)* Use Laravel helpers for null checks
+- *(ssh)* Remove Sentry retry event tracking from ExecuteRemoteCommand
+- Consolidate file path validation patterns and support scoped packages
+- *(environment-variable)* Remove buildtime/runtime options and improve comment field
+- Remove verbose logging and use explicit exception types
+- *(breadcrumb)* Optimize queries and simplify state management
+- *(scheduler)* Extract cron scheduling logic to shared helper
+- *(team)* Make server limit methods accept optional team parameter
+- *(team)* Update serverOverflow to use static serverLimit
+- *(docker)* Simplify installation and remove version pinning
### ๐ Documentation
@@ -5616,7 +5941,6 @@ All notable changes to this project will be documented in this file.
- Update changelog
- *(tests)* Update testing guidelines for unit and feature tests
- *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references
-- Update changelog
- *(database-patterns)* Add critical note on mass assignment protection for new columns
- Clarify cloud-init script compatibility
- Update changelog
@@ -5647,7 +5971,27 @@ All notable changes to this project will be documented in this file.
- Update application architecture and database patterns for request-level caching best practices
- Remove git worktree symlink instructions from CLAUDE.md
- Remove git worktree symlink instructions from CLAUDE.md (#7908)
+- Add transcript lol link and logo to readme (#7331)
+- *(api)* Change domains to urls
+- *(api)* Improve domains API docs
- Update changelog
+- Update changelog
+- *(api)* Improve app endpoint deprecation description
+- Add Coolify design system reference
+- Add Coolify design system reference (#8237)
+- Update changelog
+- Update changelog
+- Update changelog
+- *(sponsors)* Add huge sponsors section and reorganize list
+- *(application)* Add comments explaining commit selection logic for rollback support
+- *(readme)* Add VPSDime to Big Sponsors list
+- *(readme)* Move MVPS to Huge Sponsors section
+- *(settings)* Clarify Do Not Track helper text
+- Update changelog
+- Update changelog
+- *(sponsors)* Add ScreenshotOne as a huge sponsor
+- *(sponsors)* Update Brand.dev to Context.dev
+- *(readme)* Add PetroSky Cloud to sponsors
### โก Performance
@@ -5658,6 +6002,7 @@ All notable changes to this project will be documented in this file.
- Remove dead server filtering code from Kernel scheduler (#7585)
- *(server)* Optimize destinationsByServer query
- *(server)* Optimize destinationsByServer query (#7854)
+- *(breadcrumb)* Optimize queries and simplify navigation to fix OOM (#9048)
### ๐จ Styling
@@ -5670,6 +6015,7 @@ All notable changes to this project will be documented in this file.
- *(campfire)* Format environment variables for better readability in Docker Compose file
- *(campfire)* Update comment for DISABLE_SSL environment variable for clarity
- Update background colors to use gray-50 for consistency in auth views
+- *(modal-confirmation)* Improve mobile responsiveness
### ๐งช Testing
@@ -5686,6 +6032,14 @@ All notable changes to this project will be documented in this file.
- Add tests for shared environment variable spacing and resolution
- Add comprehensive preview deployment port and path tests
- Add comprehensive preview deployment port and path tests (#7677)
+- Add Pest browser testing with SQLite :memory: schema
+- Add dashboard test and improve browser test coverage
+- Migrate to SQLite :memory: and add Pest browser testing (#8364)
+- *(rollback)* Use full-length git commit SHA values in test fixtures
+- *(rollback)* Verify shell metacharacter escaping in git commit parameter
+- *(factories)* Add missing model factories for app test suite
+- *(magic-variables)* Add feature tests for SERVICE_URL/FQDN variable handling
+- Add behavioral ssh key stale-file regression
### โ๏ธ Miscellaneous Tasks
@@ -6293,10 +6647,10 @@ All notable changes to this project will be documented in this file.
- *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files
- *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively
- *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively
-- *(service)* Update Nitropage template (#6181)
-- *(versions)* Update all version
- *(bump)* Update composer deps
- *(version)* Bump Coolify version to 4.0.0-beta.420.6
+- *(service)* Update Nitropage template (#6181)
+- *(versions)* Update all version
- *(service)* Improve matrix service
- *(service)* Format runner service
- *(service)* Improve sequin
@@ -6399,6 +6753,94 @@ All notable changes to this project will be documented in this file.
- *(services)* Upgrade service template json files
- *(api)* Update openapi json and yaml
- *(api)* Regenerate openapi docs
+- Prepare for PR
+- *(api)* Improve current request error message
+- *(api)* Improve current request error message
+- *(api)* Update openapi files
+- *(service)* Update service templates json
+- *(services)* Update service template json files
+- *(service)* Use major version for openpanel (#8053)
+- Prepare for PR
+- *(services)* Update service template json files
+- Bump coolify version
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(scheduler)* Fix scheduled job duration metric (#8551)
+- Prepare for PR
+- Prepare for PR
+- *(horizon)* Make max time configurable (#8560)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(ui)* Widen project heading nav spacing (#8564)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Add pr quality check workflow
+- Do not build or generate changelog on pr-quality changes
+- Add pr quality check via anti slop action (#8344)
+- Improve pr quality workflow
+- Delete label removal workflow
+- Improve pr quality workflow (#8374)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(repo)* Improve contributor PR template
+- Add anti-slop v0.2 options to the pr-quality check
+- Improve pr template and quality check workflow (#8574)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(ui)* Add labels header
+- *(ui)* Add container labels header (#8752)
+- *(templates)* Update n8n templates to 2.10.2 (#8679)
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(version)* Bump coolify, realtime, and sentinel versions
+- *(realtime)* Upgrade npm dependencies
+- *(realtime)* Upgrade coolify-realtime to 1.0.11
+- Prepare for PR
+- Prepare for PR
+- Prepare for PR
+- *(release)* Bump version to 4.0.0-beta.466
+- Prepare for PR
+- Prepare for PR
+- *(service)* Pin castopod service to a static version instead of latest
+- *(service)* Remove unused attributes on imgcompress service
+- *(service)* Pin imgcompress to a static version instead of latest
+- *(service)* Update SeaweedFS images to version 4.13 (#8738)
+- *(templates)* Bump databasus image version
+- Remove coolify-examples-1 submodule
+- *(versions)* Bump coolify, sentinel, and traefik versions
+- *(versions)* Bump sentinel to 0.0.21
+- *(service)* Disable Booklore service (#9105)
### โ๏ธ Revert
diff --git a/CLAUDE.md b/CLAUDE.md
index 99e996756..bb65da405 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ npm run build # production build
- **Models/** โ Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`.
- **Services/** โ Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations.
- **Helpers/** โ Global helpers loaded via `bootstrap/includeHelpers.php` from `bootstrap/helpers/` โ organized into `shared.php`, `constants.php`, `versions.php`, `subscriptions.php`, `domains.php`, `docker.php`, `services.php`, `github.php`, `proxy.php`, `notifications.php`.
-- **Data/** โ Spatie Laravel Data DTOs (e.g., `CoolifyTaskArgs`, `ServerMetadata`).
+- **Data/** โ Spatie Laravel Data DTOs (e.g., `ServerMetadata`).
- **Enums/** โ PHP enums (TitleCase keys). Key enums: `ProcessStatus`, `Role` (MEMBER/ADMIN/OWNER with rank comparison), `BuildPackTypes`, `ProxyTypes`, `ContainerStatusTypes`.
- **Rules/** โ Custom validation rules (`ValidGitRepositoryUrl`, `ValidServerIp`, `ValidHostname`, `DockerImageFormat`, etc.).
diff --git a/app/Actions/CoolifyTask/PrepareCoolifyTask.php b/app/Actions/CoolifyTask/PrepareCoolifyTask.php
deleted file mode 100644
index 3f76a2e3c..000000000
--- a/app/Actions/CoolifyTask/PrepareCoolifyTask.php
+++ /dev/null
@@ -1,54 +0,0 @@
-remoteProcessArgs = $remoteProcessArgs;
-
- if ($remoteProcessArgs->model) {
- $properties = $remoteProcessArgs->toArray();
- unset($properties['model']);
-
- $this->activity = activity()
- ->withProperties($properties)
- ->performedOn($remoteProcessArgs->model)
- ->event($remoteProcessArgs->type)
- ->log('[]');
- } else {
- $this->activity = activity()
- ->withProperties($remoteProcessArgs->toArray())
- ->event($remoteProcessArgs->type)
- ->log('[]');
- }
- }
-
- public function __invoke(): Activity
- {
- $job = new CoolifyTask(
- activity: $this->activity,
- ignore_errors: $this->remoteProcessArgs->ignore_errors,
- call_event_on_finish: $this->remoteProcessArgs->call_event_on_finish,
- call_event_data: $this->remoteProcessArgs->call_event_data,
- );
- dispatch($job);
- $this->activity->refresh();
-
- return $this->activity;
- }
-}
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 9f97dd0d4..7ea6a871e 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -37,12 +37,13 @@ class CreateNewUser implements CreatesNewUsers
if (User::count() == 0) {
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
- $user = User::create([
+ $user = (new User)->forceFill([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
+ $user->save();
$team = $user->teams()->first();
// Disable registration after first user is created
diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php
index 158996c90..5baa8b7ed 100644
--- a/app/Actions/Fortify/ResetUserPassword.php
+++ b/app/Actions/Fortify/ResetUserPassword.php
@@ -21,7 +21,7 @@ class ResetUserPassword implements ResetsUserPasswords
'password' => ['required', Password::defaults(), 'confirmed'],
])->validate();
- $user->forceFill([
+ $user->fill([
'password' => Hash::make($input['password']),
])->save();
$user->deleteAllSessions();
diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php
index 0c51ec56d..320eede0b 100644
--- a/app/Actions/Fortify/UpdateUserPassword.php
+++ b/app/Actions/Fortify/UpdateUserPassword.php
@@ -24,7 +24,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
- $user->forceFill([
+ $user->fill([
'password' => Hash::make($input['password']),
])->save();
}
diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php
index c8bfd930a..76c6c0736 100644
--- a/app/Actions/Fortify/UpdateUserProfileInformation.php
+++ b/app/Actions/Fortify/UpdateUserProfileInformation.php
@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
) {
$this->updateVerifiedUser($user, $input);
} else {
- $user->forceFill([
+ $user->fill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
@@ -49,7 +49,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
protected function updateVerifiedUser(User $user, array $input): void
{
- $user->forceFill([
+ $user->fill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php
index 0a20deae5..22c48aa89 100644
--- a/app/Actions/Server/ValidateServer.php
+++ b/app/Actions/Server/ValidateServer.php
@@ -30,7 +30,8 @@ class ValidateServer
]);
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Error: '.$error.'
';
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Error: '.$sanitizedError.'
';
$server->update([
'validation_logs' => $this->error,
]);
diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php
index 8790901cd..460600d69 100644
--- a/app/Actions/Service/DeleteService.php
+++ b/app/Actions/Service/DeleteService.php
@@ -33,7 +33,7 @@ class DeleteService
}
}
foreach ($storagesToDelete as $storage) {
- $commands[] = "docker volume rm -f $storage->name";
+ $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
}
// Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index 6b5e1d4ac..17948d93b 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -40,10 +40,10 @@ class StartService
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
- $network = $service->destination->network;
+ $safeNetwork = escapeshellarg($service->destination->network);
$serviceNames = data_get(Yaml::parse($compose), 'services', []);
foreach ($serviceNames as $serviceName => $serviceConfig) {
- $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} $network {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
+ $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
}
}
diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
index a3eab4dca..d4d29af20 100644
--- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php
+++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php
@@ -4,6 +4,7 @@ namespace App\Actions\Stripe;
use App\Jobs\ServerLimitCheckJob;
use App\Models\Team;
+use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class UpdateSubscriptionQuantity
@@ -42,6 +43,7 @@ class UpdateSubscriptionQuantity
}
$currency = strtoupper($item->price->currency ?? 'usd');
+ $billingInterval = $item->price->recurring->interval ?? 'month';
// Upcoming invoice gives us the prorated amount due now
$upcomingInvoice = $this->stripe->invoices->upcoming([
@@ -99,6 +101,7 @@ class UpdateSubscriptionQuantity
'tax_description' => $taxDescription,
'quantity' => $quantity,
'currency' => $currency,
+ 'billing_interval' => $billingInterval,
],
];
} catch (\Exception $e) {
@@ -184,7 +187,7 @@ class UpdateSubscriptionQuantity
\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) {
+ } catch (InvalidRequestException $e) {
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php
index acc6dc2f9..7daa6ba28 100644
--- a/app/Console/Commands/Dev.php
+++ b/app/Console/Commands/Dev.php
@@ -30,32 +30,32 @@ class Dev extends Command
// Generate APP_KEY if not exists
if (empty(config('app.key'))) {
- echo "Generating APP_KEY.\n";
+ echo " INFO Generating APP_KEY.\n";
Artisan::call('key:generate');
}
// Generate STORAGE link if not exists
if (! file_exists(public_path('storage'))) {
- echo "Generating STORAGE link.\n";
+ echo " INFO Generating storage link.\n";
Artisan::call('storage:link');
}
// Seed database if it's empty
$settings = InstanceSettings::find(0);
if (! $settings) {
- echo "Initializing instance, seeding database.\n";
+ echo " INFO Initializing instance, seeding database.\n";
Artisan::call('migrate --seed');
} else {
- echo "Instance already initialized.\n";
+ echo " INFO Instance already initialized.\n";
}
// Clean up stuck jobs and stale locks on development startup
try {
- echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
+ echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n";
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
- echo "Redis cleanup completed.\n";
+ echo " INFO Redis cleanup completed.\n";
} catch (\Throwable $e) {
- echo "Error in cleanup:redis: {$e->getMessage()}\n";
+ echo " ERROR Redis cleanup failed: {$e->getMessage()}\n";
}
try {
@@ -66,10 +66,10 @@ class Dev extends Command
]);
if ($updatedTaskCount > 0) {
- echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
+ echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n";
}
} catch (\Throwable $e) {
- echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
+ echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
@@ -80,10 +80,10 @@ class Dev extends Command
]);
if ($updatedBackupCount > 0) {
- echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
+ echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n";
}
} catch (\Throwable $e) {
- echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
+ echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n";
}
CheckHelperImageJob::dispatch();
diff --git a/app/Console/Commands/Horizon.php b/app/Console/Commands/Horizon.php
deleted file mode 100644
index d3e35ca5a..000000000
--- a/app/Console/Commands/Horizon.php
+++ /dev/null
@@ -1,23 +0,0 @@
-info('Horizon is enabled on this server.');
- $this->call('horizon');
- exit(0);
- } else {
- exit(0);
- }
- }
-}
diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php
index 66cb77838..e95c29f72 100644
--- a/app/Console/Commands/Init.php
+++ b/app/Console/Commands/Init.php
@@ -212,18 +212,19 @@ class Init extends Command
$removeNetworks = $allNetworks->diff($networks);
$commands = collect();
foreach ($removeNetworks as $network) {
- $out = instant_remote_process(["docker network inspect -f json $network | jq '.[].Containers | if . == {} then null else . end'"], $server, false);
+ $safe = escapeshellarg($network);
+ $out = instant_remote_process(["docker network inspect -f json {$safe} | jq '.[].Containers | if . == {} then null else . end'"], $server, false);
if (empty($out)) {
- $commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true");
- $commands->push("docker network rm $network >/dev/null 2>&1 || true");
+ $commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true");
+ $commands->push("docker network rm {$safe} >/dev/null 2>&1 || true");
} else {
$data = collect(json_decode($out, true));
if ($data->count() === 1) {
// If only coolify-proxy itself is connected to that network (it should not be possible, but who knows)
$isCoolifyProxyItself = data_get($data->first(), 'Name') === 'coolify-proxy';
if ($isCoolifyProxyItself) {
- $commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true");
- $commands->push("docker network rm $network >/dev/null 2>&1 || true");
+ $commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true");
+ $commands->push("docker network rm {$safe} >/dev/null 2>&1 || true");
}
}
}
diff --git a/app/Console/Commands/Nightwatch.php b/app/Console/Commands/Nightwatch.php
deleted file mode 100644
index 40fd86a81..000000000
--- a/app/Console/Commands/Nightwatch.php
+++ /dev/null
@@ -1,22 +0,0 @@
-info('Nightwatch is enabled on this server.');
- $this->call('nightwatch:agent');
- }
-
- exit(0);
- }
-}
diff --git a/app/Console/Commands/Scheduler.php b/app/Console/Commands/Scheduler.php
deleted file mode 100644
index ee64368c3..000000000
--- a/app/Console/Commands/Scheduler.php
+++ /dev/null
@@ -1,23 +0,0 @@
-info('Scheduler is enabled on this server.');
- $this->call('schedule:work');
- exit(0);
- } else {
- exit(0);
- }
- }
-}
diff --git a/app/Data/CoolifyTaskArgs.php b/app/Data/CoolifyTaskArgs.php
deleted file mode 100644
index 24132157a..000000000
--- a/app/Data/CoolifyTaskArgs.php
+++ /dev/null
@@ -1,30 +0,0 @@
-status = ProcessStatus::QUEUED->value;
- }
- }
-}
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index b081069b7..77f4e626f 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -20,6 +20,7 @@ use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
+use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
@@ -229,6 +230,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
),
@@ -394,6 +396,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
),
@@ -559,6 +562,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
),
@@ -1005,7 +1009,7 @@ class ApplicationsController extends Controller
if ($return instanceof 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_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'];
+ $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', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1054,6 +1058,7 @@ class ApplicationsController extends Controller
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
+ $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -1157,7 +1162,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
- $application->fill($request->all());
+ $application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
@@ -1266,6 +1271,10 @@ class ApplicationsController extends Controller
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
+ if (isset($isPreserveRepositoryEnabled)) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
@@ -1384,7 +1393,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
- $application->fill($request->all());
+ $application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
@@ -1498,6 +1507,10 @@ class ApplicationsController extends Controller
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
+ if (isset($isPreserveRepositoryEnabled)) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1584,7 +1597,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
- $application->fill($request->all());
+ $application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
@@ -1694,6 +1707,10 @@ class ApplicationsController extends Controller
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
+ if (isset($isPreserveRepositoryEnabled)) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1771,7 +1788,7 @@ class ApplicationsController extends Controller
}
$application = new Application;
- $application->fill($request->all());
+ $application->fill($request->only($allowedFields));
$application->fqdn = $fqdn;
$application->ports_exposes = $port;
$application->build_pack = 'dockerfile';
@@ -1883,7 +1900,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
- $application->fill($request->all());
+ $application->fill($request->only($allowedFields));
$application->fqdn = $fqdn;
$application->build_pack = 'dockerimage';
$application->destination_id = $destination->id;
@@ -1999,7 +2016,7 @@ class ApplicationsController extends Controller
$service = new Service;
removeUnnecessaryFieldsFromRequest($request);
- $service->fill($request->all());
+ $service->fill($request->only($allowedFields));
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
@@ -2389,6 +2406,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
],
)
),
@@ -2474,7 +2492,7 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
- $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -2721,7 +2739,7 @@ class ApplicationsController extends Controller
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
-
+ $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -2756,10 +2774,13 @@ class ApplicationsController extends Controller
$application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
$application->settings->save();
}
-
+ if ($request->has('is_preserve_repository_enabled')) {
+ $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
+ $application->settings->save();
+ }
removeUnnecessaryFieldsFromRequest($request);
- $data = $request->all();
+ $data = $request->only($allowedFields);
if ($requestHasDomains && $server->isProxyShouldRun()) {
data_set($data, 'fqdn', $domains);
}
@@ -4096,7 +4117,7 @@ class ApplicationsController extends Controller
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
@@ -4274,7 +4295,7 @@ class ApplicationsController extends Controller
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index f9e171eee..8e31a7051 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -19,6 +19,7 @@ use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql;
+use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -263,6 +264,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -326,7 +328,7 @@ class DatabasesController extends Controller
)]
public function update_by_uuid(Request $request)
{
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
@@ -343,6 +345,7 @@ class DatabasesController extends Controller
'image' => 'string',
'is_public' => 'boolean',
'public_port' => 'numeric|nullable',
+ 'public_port_timeout' => 'integer|nullable|min:1',
'limits_memory' => 'string',
'limits_memory_swap' => 'string',
'limits_memory_swappiness' => 'numeric',
@@ -374,7 +377,7 @@ class DatabasesController extends Controller
}
switch ($database->type()) {
case 'standalone-postgresql':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$validator = customApiValidator($request->all(), [
'postgres_user' => 'string',
'postgres_password' => 'string',
@@ -405,20 +408,20 @@ class DatabasesController extends Controller
}
break;
case 'standalone-clickhouse':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
]);
break;
case 'standalone-dragonfly':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string',
]);
break;
case 'standalone-redis':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [
'redis_password' => 'string',
'redis_conf' => 'string',
@@ -445,7 +448,7 @@ class DatabasesController extends Controller
}
break;
case 'standalone-keydb':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [
'keydb_password' => 'string',
'keydb_conf' => 'string',
@@ -472,7 +475,7 @@ class DatabasesController extends Controller
}
break;
case 'standalone-mariadb':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string',
'mariadb_root_password' => 'string',
@@ -502,7 +505,7 @@ class DatabasesController extends Controller
}
break;
case 'standalone-mongodb':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string',
@@ -532,7 +535,7 @@ class DatabasesController extends Controller
break;
case 'standalone-mysql':
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string',
'mysql_password' => 'string',
@@ -640,6 +643,7 @@ class DatabasesController extends Controller
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
+ 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
)
@@ -676,7 +680,7 @@ class DatabasesController extends Controller
)]
public function create_backup(Request $request)
{
- $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
+ $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -703,6 +707,7 @@ class DatabasesController extends Controller
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -877,6 +882,7 @@ class DatabasesController extends Controller
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
+ 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
)
@@ -906,7 +912,7 @@ class DatabasesController extends Controller
)]
public function update_backup(Request $request)
{
- $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
+ $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -924,13 +930,14 @@ class DatabasesController extends Controller
'dump_all' => 'boolean',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
- 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
+ 'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
return response()->json([
@@ -957,6 +964,17 @@ class DatabasesController extends Controller
$this->authorize('update', $database);
+ // Validate frequency is a valid cron expression
+ if ($request->filled('frequency')) {
+ $isValid = validate_cron_expression($request->frequency);
+ if (! $isValid) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
+ ], 422);
+ }
+ }
+
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
return response()->json([
'message' => 'Validation failed.',
@@ -1067,6 +1085,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1134,6 +1153,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1200,6 +1220,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1267,6 +1288,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1334,6 +1356,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1404,6 +1427,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1474,6 +1498,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1541,6 +1566,7 @@ class DatabasesController extends Controller
'image' => ['type' => 'string', 'description' => 'Docker Image of the database'],
'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'],
'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'],
+ 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness of the database'],
@@ -1579,7 +1605,7 @@ class DatabasesController extends Controller
public function create_database(Request $request, NewDatabaseTypes $type)
{
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -1669,6 +1695,7 @@ class DatabasesController extends Controller
'destination_uuid' => 'string',
'is_public' => 'boolean',
'public_port' => 'numeric|nullable',
+ 'public_port_timeout' => 'integer|nullable|min:1',
'limits_memory' => 'string',
'limits_memory_swap' => 'string',
'limits_memory_swappiness' => 'numeric',
@@ -1695,7 +1722,7 @@ class DatabasesController extends Controller
}
}
if ($type === NewDatabaseTypes::POSTGRESQL) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$validator = customApiValidator($request->all(), [
'postgres_user' => 'string',
'postgres_password' => 'string',
@@ -1739,7 +1766,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('postgres_conf', $postgresConf);
}
- $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1754,7 +1781,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
@@ -1794,7 +1821,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('mariadb_conf', $mariadbConf);
}
- $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1810,7 +1837,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string',
'mysql_password' => 'string',
@@ -1853,7 +1880,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('mysql_conf', $mysqlConf);
}
- $database = create_standalone_mysql($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1869,7 +1896,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [
'redis_password' => 'string',
'redis_conf' => 'string',
@@ -1909,7 +1936,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('redis_conf', $redisConf);
}
- $database = create_standalone_redis($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1925,7 +1952,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string',
]);
@@ -1946,7 +1973,7 @@ class DatabasesController extends Controller
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1955,7 +1982,7 @@ class DatabasesController extends Controller
'uuid' => $database->uuid,
]))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::KEYDB) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [
'keydb_password' => 'string',
'keydb_conf' => 'string',
@@ -1995,7 +2022,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('keydb_conf', $keydbConf);
}
- $database = create_standalone_keydb($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2011,7 +2038,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
@@ -2031,7 +2058,7 @@ class DatabasesController extends Controller
], 422);
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2047,7 +2074,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string',
@@ -2089,7 +2116,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('mongo_conf', $mongoConf);
}
- $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -3467,7 +3494,7 @@ class DatabasesController extends Controller
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
@@ -3665,7 +3692,7 @@ class DatabasesController extends Controller
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 85d532f62..6ff06c10a 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -4,12 +4,15 @@ namespace App\Http\Controllers\Api;
use App\Actions\Database\StartDatabase;
use App\Actions\Service\StartService;
+use App\Enums\ApplicationDeploymentStatus;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
+use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\Service;
use App\Models\Tag;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
@@ -228,8 +231,8 @@ class DeployController extends Controller
// Check if deployment can be cancelled (must be queued or in_progress)
$cancellableStatuses = [
- \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
- \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ApplicationDeploymentStatus::QUEUED->value,
+ ApplicationDeploymentStatus::IN_PROGRESS->value,
];
if (! in_array($deployment->status, $cancellableStatuses)) {
@@ -246,11 +249,11 @@ class DeployController extends Controller
// Mark deployment as cancelled
$deployment->update([
- 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Get the server
- $server = Server::find($build_server_id);
+ $server = Server::whereTeamId($teamId)->find($build_server_id);
if ($server) {
// Add cancellation log entry
@@ -304,6 +307,8 @@ class DeployController extends Controller
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
+ new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')),
+ new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
],
responses: [
@@ -354,7 +359,9 @@ class DeployController extends Controller
$uuids = $request->input('uuid');
$tags = $request->input('tag');
$force = $request->input('force') ?? false;
- $pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0;
+ $pullRequestId = $request->input('pull_request_id', $request->input('pr'));
+ $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
+ $dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
if ($uuids && $tags) {
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
@@ -362,16 +369,22 @@ class DeployController extends Controller
if ($tags && $pr) {
return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
}
+ if ($dockerTag && $pr === 0) {
+ return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400);
+ }
+ if ($dockerTag && $tags) {
+ return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400);
+ }
if ($tags) {
return $this->by_tags($tags, $teamId, $force);
} elseif ($uuids) {
- return $this->by_uuids($uuids, $teamId, $force, $pr);
+ return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag);
}
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
}
- private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
+ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null)
{
$uuids = explode(',', $uuid);
$uuids = collect(array_filter($uuids));
@@ -384,15 +397,22 @@ class DeployController extends Controller
foreach ($uuids as $uuid) {
$resource = getResourceByUuid($uuid, $teamId);
if ($resource) {
+ $dockerTagForResource = $dockerTag;
if ($pr !== 0) {
- $preview = $resource->previews()->where('pull_request_id', $pr)->first();
+ $preview = null;
+ if ($resource instanceof Application && $resource->build_pack === 'dockerimage') {
+ $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag);
+ $dockerTagForResource = $preview?->docker_registry_image_tag;
+ } else {
+ $preview = $resource->previews()->where('pull_request_id', $pr)->first();
+ }
if (! $preview) {
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
continue;
}
}
- $result = $this->deploy_resource($resource, $force, $pr);
+ $result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
@@ -465,7 +485,7 @@ class DeployController extends Controller
return response()->json(['message' => 'No resources found with this tag.'], 404);
}
- public function deploy_resource($resource, bool $force = false, int $pr = 0): array
+ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array
{
$message = null;
$deployment_uuid = null;
@@ -477,9 +497,12 @@ class DeployController extends Controller
// Check authorization for application deployment
try {
$this->authorize('deploy', $resource);
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
}
+ if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
+ return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $resource,
@@ -487,6 +510,7 @@ class DeployController extends Controller
force_rebuild: $force,
pull_request_id: $pr,
is_api: true,
+ docker_registry_image_tag: $dockerTag,
);
if ($result['status'] === 'queue_full') {
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
@@ -500,7 +524,7 @@ class DeployController extends Controller
// Check authorization for service deployment
try {
$this->authorize('deploy', $resource);
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
}
StartService::run($resource);
@@ -510,7 +534,7 @@ class DeployController extends Controller
// Database resource - check authorization
try {
$this->authorize('manage', $resource);
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
}
StartDatabase::dispatch($resource);
@@ -525,6 +549,34 @@ class DeployController extends Controller
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
}
+ private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview
+ {
+ $preview = $application->previews()->where('pull_request_id', $pullRequestId)->first();
+
+ if (! $preview && $dockerTag === null) {
+ return null;
+ }
+
+ if (! $preview) {
+ $preview = ApplicationPreview::create([
+ 'application_id' => $application->id,
+ 'pull_request_id' => $pullRequestId,
+ 'pull_request_html_url' => '',
+ 'docker_registry_image_tag' => $dockerTag,
+ ]);
+ $preview->generate_preview_fqdn();
+
+ return $preview;
+ }
+
+ if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) {
+ $preview->docker_registry_image_tag = $dockerTag;
+ $preview->save();
+ }
+
+ return $preview;
+ }
+
#[OA\Get(
summary: 'List application deployments',
description: 'List application deployments by using the app uuid',
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index f6a6b3513..9a2cf2b9f 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -5,6 +5,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
+use App\Rules\SafeExternalUrl;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
@@ -181,7 +184,7 @@ class GithubController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -204,8 +207,8 @@ class GithubController extends Controller
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
- 'api_url' => 'required|string|url',
- 'html_url' => 'required|string|url',
+ 'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
+ 'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
@@ -370,7 +373,7 @@ class GithubController extends Controller
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -472,7 +475,7 @@ class GithubController extends Controller
return response()->json([
'branches' => $branches,
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -587,10 +590,10 @@ class GithubController extends Controller
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
- $rules['api_url'] = 'url';
+ $rules['api_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['html_url'])) {
- $rules['html_url'] = 'url';
+ $rules['html_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
@@ -651,7 +654,7 @@ class GithubController extends Controller
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
@@ -736,7 +739,7 @@ class GithubController extends Controller
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
- } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
+ } catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index da553a68c..ec2e300ff 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Support\ValidationPatterns;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -234,7 +235,7 @@ class ProjectController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
@@ -347,7 +348,7 @@ class ProjectController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
@@ -600,7 +601,7 @@ class ProjectController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index e7b36cb9a..2c62928c2 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PrivateKey;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -176,7 +177,7 @@ class SecurityController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -300,7 +301,7 @@ class SecurityController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -330,7 +331,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
- $foundKey->update($request->all());
+ $foundKey->update($request->only($allowedFields));
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 2ef95ce8b..c13c6665c 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -598,6 +598,11 @@ class ServersController extends Controller
'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'],
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
+ 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
+ 'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
+ 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
+ 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
+ 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
],
),
),
@@ -634,7 +639,7 @@ class ServersController extends Controller
)]
public function update_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -655,6 +660,11 @@ class ServersController extends Controller
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
+ 'concurrent_builds' => 'integer|min:1',
+ 'dynamic_timeout' => 'integer|min:1',
+ 'deployment_queue_limit' => 'integer|min:1',
+ 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
+ 'server_disk_usage_check_frequency' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -691,6 +701,19 @@ class ServersController extends Controller
'is_build_server' => $request->is_build_server,
]);
}
+
+ if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']],
+ ], 422);
+ }
+
+ $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
+ if (! empty($advancedSettings)) {
+ $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
+ }
+
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 89635875c..fbf4b9e56 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -13,6 +13,7 @@ use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
+use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
@@ -2015,7 +2016,7 @@ class ServicesController extends Controller
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'resource_uuid' => 'required|string',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
@@ -2224,7 +2225,7 @@ class ServicesController extends Controller
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
- 'name' => 'string',
+ 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php
index fd0282d96..03b36e4e0 100644
--- a/app/Http/Controllers/Api/TeamController.php
+++ b/app/Http/Controllers/Api/TeamController.php
@@ -14,14 +14,6 @@ class TeamController extends Controller
'custom_server_limit',
'pivot',
]);
- if (request()->attributes->get('can_read_sensitive', false) === false) {
- $team->makeHidden([
- 'smtp_username',
- 'smtp_password',
- 'resend_api_key',
- 'telegram_token',
- ]);
- }
return serializeApiResponse($team);
}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 09007ad96..17d14296b 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -108,9 +108,31 @@ class Controller extends BaseController
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
+ public function showInvitation()
+ {
+ $invitationUuid = request()->route('uuid');
+ $invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
+ $user = User::whereEmail($invitation->email)->firstOrFail();
+
+ if (Auth::id() !== $user->id) {
+ abort(400, 'You are not allowed to accept this invitation.');
+ }
+
+ if (! $invitation->isValid()) {
+ abort(400, 'Invitation expired.');
+ }
+
+ $alreadyMember = $user->teams()->where('team_id', $invitation->team->id)->exists();
+
+ return view('invitation.accept', [
+ 'invitation' => $invitation,
+ 'team' => $invitation->team,
+ 'alreadyMember' => $alreadyMember,
+ ]);
+ }
+
public function acceptInvitation()
{
- $resetPassword = request()->query('reset-password');
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
@@ -119,43 +141,21 @@ class Controller extends BaseController
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
- $invitationValid = $invitation->isValid();
- if ($invitationValid) {
- if ($resetPassword) {
- $user->update([
- 'password' => Hash::make($invitationUuid),
- 'force_password_reset' => true,
- ]);
- }
- if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
- $invitation->delete();
-
- return redirect()->route('team.index');
- }
- $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
- $invitation->delete();
-
- refreshSession($invitation->team);
-
- return redirect()->route('team.index');
- } else {
+ if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
- }
- public function revokeInvitation()
- {
- $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
- $user = User::whereEmail($invitation->email)->firstOrFail();
- if (is_null(Auth::user())) {
- return redirect()->route('login');
- }
- if (Auth::id() !== $user->id) {
- abort(401);
+ if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
+ $invitation->delete();
+
+ return redirect()->route('team.index');
}
+ $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
+ refreshSession($invitation->team);
+
return redirect()->route('team.index');
}
}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 785e8c8e3..3c52e03a1 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -76,6 +76,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private ?string $dockerImageTag = null;
+ private ?string $dockerImagePreviewTag = null;
+
private GithubApp|GitlabApp|string $source = 'other';
private StandaloneDocker|SwarmDocker $destination;
@@ -208,6 +210,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->restart_only = $this->application_deployment_queue->restart_only;
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
$this->only_this_server = $this->application_deployment_queue->only_this_server;
+ $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
@@ -246,6 +249,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Set preview fqdn
if ($this->pull_request_id !== 0) {
$this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id);
+ if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) {
+ $this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag;
+ }
if ($this->preview) {
if ($this->application->build_pack === 'dockercompose') {
$this->preview->generate_preview_fqdn_compose();
@@ -288,7 +294,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Make sure the private key is stored in the filesystem
$this->server->privateKey->storeInFileSystem();
// Generate custom host<->ip mapping
- $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server);
+ $safeNetwork = escapeshellarg($this->destination->network);
+ $allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server);
if (! is_null($allContainers)) {
$allContainers = format_docker_command_output_to_json($allContainers);
@@ -465,14 +472,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->just_restart();
return;
+ } elseif ($this->application->build_pack === 'dockerimage') {
+ $this->deploy_dockerimage_buildpack();
} elseif ($this->pull_request_id !== 0) {
$this->deploy_pull_request();
} elseif ($this->application->dockerfile) {
$this->deploy_simple_dockerfile();
} elseif ($this->application->build_pack === 'dockercompose') {
$this->deploy_docker_compose_buildpack();
- } elseif ($this->application->build_pack === 'dockerimage') {
- $this->deploy_dockerimage_buildpack();
} elseif ($this->application->build_pack === 'dockerfile') {
$this->deploy_dockerfile_buildpack();
} elseif ($this->application->build_pack === 'static') {
@@ -553,11 +560,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function deploy_dockerimage_buildpack()
{
$this->dockerImage = $this->application->docker_registry_image_name;
- if (str($this->application->docker_registry_image_tag)->isEmpty()) {
- $this->dockerImageTag = 'latest';
- } else {
- $this->dockerImageTag = $this->application->docker_registry_image_tag;
- }
+ $this->dockerImageTag = $this->resolveDockerImageTag();
// Check if this is an image hash deployment
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
@@ -574,6 +577,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->rolling_update();
}
+ private function resolveDockerImageTag(): string
+ {
+ if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) {
+ return $this->dockerImagePreviewTag;
+ }
+
+ if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
+ return $this->application->docker_registry_image_tag;
+ }
+
+ return 'latest';
+ }
+
private function deploy_docker_compose_buildpack()
{
if (data_get($this->application, 'docker_compose_location')) {
@@ -1266,7 +1282,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables as $env) {
- $envs->push($env->key.'='.$env->real_value);
+ $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1332,7 +1348,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables_preview as $env) {
- $envs->push($env->key.'='.$env->real_value);
+ $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
}
// Fall back to production env vars for keys not overridden by preview vars,
@@ -1346,7 +1362,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
- $envs->push($env->key.'='.$env->real_value);
+ $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
}
}
@@ -1588,10 +1604,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
+ $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
- $value = trim($env->real_value, "'");
+ $value = trim($resolvedValue, "'");
$escapedValue = escapeBashEnvValue($value);
if (isDev() && isset($envs_dict[$env->key])) {
@@ -1603,13 +1620,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
- $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
+ $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$resolvedValue}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
- $escapedValue = escapeBashDoubleQuoted($env->real_value);
+ $escapedValue = escapeBashDoubleQuoted($resolvedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
@@ -1620,7 +1637,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
- $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
+ $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$resolvedValue}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
@@ -1639,10 +1656,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
+ $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
- $value = trim($env->real_value, "'");
+ $value = trim($resolvedValue, "'");
$escapedValue = escapeBashEnvValue($value);
if (isDev() && isset($envs_dict[$env->key])) {
@@ -1654,13 +1672,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
- $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
+ $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$resolvedValue}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
- $escapedValue = escapeBashDoubleQuoted($env->real_value);
+ $escapedValue = escapeBashDoubleQuoted($resolvedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
@@ -1671,7 +1689,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
- $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
+ $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$resolvedValue}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
@@ -1933,6 +1951,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function deploy_pull_request()
{
+ if ($this->application->build_pack === 'dockerimage') {
+ $this->deploy_dockerimage_buildpack();
+
+ return;
+ }
if ($this->application->build_pack === 'dockercompose') {
$this->deploy_docker_compose_buildpack();
@@ -2015,9 +2038,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
if ($this->dockerConfigFileExists === 'OK') {
- $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $safeNetwork = escapeshellarg($this->destination->network);
+ $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
- $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $safeNetwork = escapeshellarg($this->destination->network);
+ $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
if ($firstTry) {
@@ -2369,15 +2394,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = collect([]);
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
- if (! is_null($env->real_value) && $env->real_value !== '') {
- $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
+ $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
+ if (! is_null($resolvedValue) && $resolvedValue !== '') {
+ $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
- if (! is_null($env->real_value) && $env->real_value !== '') {
- $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
+ $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
+ if (! is_null($resolvedValue) && $resolvedValue !== '') {
+ $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
@@ -2516,8 +2543,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
->get();
foreach ($envs as $env) {
- if (! is_null($env->real_value)) {
- $this->env_args->put($env->key, $env->real_value);
+ $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
+ if (! is_null($resolvedValue)) {
+ $this->env_args->put($env->key, $resolvedValue);
}
}
} else {
@@ -2527,8 +2555,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
->get();
foreach ($envs as $env) {
- if (! is_null($env->real_value)) {
- $this->env_args->put($env->key, $env->real_value);
+ $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
+ if (! is_null($resolvedValue)) {
+ $this->env_args->put($env->key, $resolvedValue);
}
}
}
@@ -3046,28 +3075,29 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
+ $safeNetwork = escapeshellarg($this->destination->network);
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}" : '';
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->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$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->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without 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} --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 {$safeNetwork} -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} --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 {$safeNetwork} -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} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -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} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3542,7 +3572,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$secrets_string = $variables
->map(function ($env) {
- return "{$env->key}={$env->real_value}";
+ return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
})
->sort()
->implode('|');
@@ -3608,7 +3638,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
- $argsToInsert->push("ARG {$env->key}={$env->real_value}");
+ $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
}
}
// Add Coolify variables as ARGs
@@ -3630,7 +3660,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
- $argsToInsert->push("ARG {$env->key}={$env->real_value}");
+ $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
}
}
// Add Coolify variables as ARGs
@@ -3666,7 +3696,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
- return [$env->key => $env->real_value];
+ return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 7f1feaa21..a2d08e1e8 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -678,6 +678,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
$network = $this->database->destination->network;
}
+ $safeNetwork = escapeshellarg($network);
$fullImageName = $this->getFullImageName();
@@ -689,13 +690,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
- $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
+ $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
- $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
+ $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
}
} else {
- $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
+ $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
// Escape S3 credentials to prevent command injection
diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php
index 607fda3fe..9d2a94606 100644
--- a/app/Jobs/SendWebhookJob.php
+++ b/app/Jobs/SendWebhookJob.php
@@ -9,6 +9,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Validator;
class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -40,6 +42,20 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
+ $validator = Validator::make(
+ ['webhook_url' => $this->webhookUrl],
+ ['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]]
+ );
+
+ if ($validator->fails()) {
+ Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
+ 'url' => $this->webhookUrl,
+ 'errors' => $validator->errors()->all(),
+ ]);
+
+ return;
+ }
+
if (isDev()) {
ray('Sending webhook notification', [
'url' => $this->webhookUrl,
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index 288904471..ee8cf2797 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -45,7 +45,8 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
// Validate connection
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if (! $uptime) {
- $errorMessage = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Error: '.$error;
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $errorMessage = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Error: '.$sanitizedError;
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
@@ -197,7 +198,7 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
]);
$this->server->update([
- 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
+ 'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'),
'is_validating' => false,
]);
}
diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php
index 85ba60c33..665d14ba0 100644
--- a/app/Livewire/ActivityMonitor.php
+++ b/app/Livewire/ActivityMonitor.php
@@ -2,7 +2,9 @@
namespace App\Livewire;
+use App\Models\Server;
use App\Models\User;
+use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
@@ -10,6 +12,7 @@ class ActivityMonitor extends Component
{
public ?string $header = null;
+ #[Locked]
public $activityId = null;
public $eventToDispatch = 'activityFinished';
@@ -57,25 +60,47 @@ class ActivityMonitor extends Component
$activity = Activity::find($this->activityId);
- if ($activity) {
- $teamId = data_get($activity, 'properties.team_id');
- if ($teamId && $teamId !== currentTeam()?->id) {
+ if (! $activity) {
+ $this->activity = null;
+
+ return;
+ }
+
+ $currentTeamId = currentTeam()?->id;
+
+ // Check team_id stored directly in activity properties
+ $activityTeamId = data_get($activity, 'properties.team_id');
+ if ($activityTeamId !== null) {
+ if ((int) $activityTeamId !== (int) $currentTeamId) {
$this->activity = null;
return;
}
+
+ $this->activity = $activity;
+
+ return;
+ }
+
+ // Fallback: verify ownership via the server that ran the command
+ $serverUuid = data_get($activity, 'properties.server_uuid');
+ if ($serverUuid) {
+ $server = Server::where('uuid', $serverUuid)->first();
+ if ($server && (int) $server->team_id !== (int) $currentTeamId) {
+ $this->activity = null;
+
+ return;
+ }
+
+ if ($server) {
+ $this->activity = $activity;
+
+ return;
+ }
}
- $this->activity = $activity;
- }
-
- public function updatedActivityId($value)
- {
- if ($value) {
- $this->hydrateActivity();
- $this->isPollingActive = true;
- self::$eventDispatched = false;
- }
+ // Fail closed: no team_id and no server_uuid means we cannot verify ownership
+ $this->activity = null;
}
public function polling()
diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php
index b5f6d2929..d1345e7bf 100644
--- a/app/Livewire/Admin/Index.php
+++ b/app/Livewire/Admin/Index.php
@@ -6,7 +6,6 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class Index extends Component
@@ -22,16 +21,15 @@ class Index extends Component
public function mount()
{
if (! isCloud() && ! isDev()) {
- return redirect()->route('dashboard');
- }
- if (Auth::id() !== 0 && ! session('impersonating')) {
- return redirect()->route('dashboard');
+ abort(403);
}
+ $this->authorizeAdminAccess();
$this->getSubscribers();
}
public function back()
{
+ $this->authorizeAdminAccess();
if (session('impersonating')) {
session()->forget('impersonating');
$user = User::find(0);
@@ -45,6 +43,7 @@ class Index extends Component
public function submitSearch()
{
+ $this->authorizeAdminAccess();
if ($this->search !== '') {
$this->foundUsers = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
@@ -61,19 +60,33 @@ class Index extends Component
public function switchUser(int $user_id)
{
- if (Auth::id() !== 0) {
- return redirect()->route('dashboard');
- }
+ $this->authorizeRootOnly();
session(['impersonating' => true]);
$user = User::find($user_id);
+ if (! $user) {
+ abort(404);
+ }
$team_to_switch_to = $user->teams->first();
- // Cache::forget("team:{$user->id}");
Auth::login($user);
refreshSession($team_to_switch_to);
return redirect(request()->header('Referer'));
}
+ private function authorizeAdminAccess(): void
+ {
+ if (! Auth::check() || (Auth::id() !== 0 && ! session('impersonating'))) {
+ abort(403);
+ }
+ }
+
+ private function authorizeRootOnly(): void
+ {
+ if (! Auth::check() || Auth::id() !== 0) {
+ abort(403);
+ }
+ }
+
public function render()
{
return view('livewire.admin.index');
diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php
index 0f6f45d83..33c75bf70 100644
--- a/app/Livewire/Boarding/Index.php
+++ b/app/Livewire/Boarding/Index.php
@@ -9,6 +9,7 @@ use App\Models\Server;
use App\Models\Team;
use App\Services\ConfigurationRepository;
use Illuminate\Support\Collection;
+use Livewire\Attributes\Url;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -19,18 +20,18 @@ class Index extends Component
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
];
- #[\Livewire\Attributes\Url(as: 'step', history: true)]
+ #[Url(as: 'step', history: true)]
public string $currentState = 'welcome';
- #[\Livewire\Attributes\Url(keep: true)]
+ #[Url(keep: true)]
public ?string $selectedServerType = null;
public ?Collection $privateKeys = null;
- #[\Livewire\Attributes\Url(keep: true)]
+ #[Url(keep: true)]
public ?int $selectedExistingPrivateKey = null;
- #[\Livewire\Attributes\Url(keep: true)]
+ #[Url(keep: true)]
public ?string $privateKeyType = null;
public ?string $privateKey = null;
@@ -45,7 +46,7 @@ class Index extends Component
public ?Collection $servers = null;
- #[\Livewire\Attributes\Url(keep: true)]
+ #[Url(keep: true)]
public ?int $selectedExistingServer = null;
public ?string $remoteServerName = null;
@@ -66,7 +67,7 @@ class Index extends Component
public Collection $projects;
- #[\Livewire\Attributes\Url(keep: true)]
+ #[Url(keep: true)]
public ?int $selectedProject = null;
public ?Project $createdProject = null;
@@ -121,7 +122,7 @@ class Index extends Component
}
if ($this->selectedExistingServer) {
- $this->createdServer = Server::find($this->selectedExistingServer);
+ $this->createdServer = Server::ownedByCurrentTeam()->find($this->selectedExistingServer);
if ($this->createdServer) {
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails();
@@ -145,7 +146,7 @@ class Index extends Component
}
if ($this->selectedProject) {
- $this->createdProject = Project::find($this->selectedProject);
+ $this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject);
if (! $this->createdProject) {
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
}
@@ -431,7 +432,10 @@ class Index extends Component
public function selectExistingProject()
{
- $this->createdProject = Project::find($this->selectedProject);
+ $this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject);
+ if (! $this->createdProject) {
+ return $this->dispatch('error', 'Project not found.');
+ }
$this->currentState = 'create-resource';
}
diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php
index 70751fa03..6f9b6f995 100644
--- a/app/Livewire/Destination/New/Docker.php
+++ b/app/Livewire/Destination/New/Docker.php
@@ -24,7 +24,7 @@ class Docker extends Component
#[Validate(['required', 'string'])]
public string $name;
- #[Validate(['required', 'string'])]
+ #[Validate(['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])]
public string $network;
#[Validate(['required', 'string'])]
diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php
index 98cf72376..f2cdad074 100644
--- a/app/Livewire/Destination/Show.php
+++ b/app/Livewire/Destination/Show.php
@@ -20,7 +20,7 @@ class Show extends Component
#[Validate(['string', 'required'])]
public string $name;
- #[Validate(['string', 'required'])]
+ #[Validate(['string', 'required', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])]
public string $network;
#[Validate(['string', 'required'])]
@@ -84,8 +84,9 @@ class Show extends Component
if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
}
- instant_remote_process(["docker network disconnect {$this->destination->network} coolify-proxy"], $this->destination->server, throwError: false);
- instant_remote_process(['docker network rm -f '.$this->destination->network], $this->destination->server);
+ $safeNetwork = escapeshellarg($this->destination->network);
+ instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false);
+ instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server);
}
$this->destination->delete();
diff --git a/app/Livewire/ForcePasswordReset.php b/app/Livewire/ForcePasswordReset.php
index 61a2a20e9..e6392497f 100644
--- a/app/Livewire/ForcePasswordReset.php
+++ b/app/Livewire/ForcePasswordReset.php
@@ -48,7 +48,7 @@ class ForcePasswordReset extends Component
$this->rateLimit(10);
$this->validate();
$firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
- auth()->user()->forceFill([
+ auth()->user()->fill([
'password' => Hash::make($this->password),
'force_password_reset' => false,
])->save();
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index f910110dc..154748b47 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -1203,7 +1203,7 @@ class GlobalSearch extends Component
public function loadDestinations()
{
$this->loadingDestinations = true;
- $server = Server::find($this->selectedServerId);
+ $server = Server::ownedByCurrentTeam()->find($this->selectedServerId);
if (! $server) {
$this->loadingDestinations = false;
@@ -1280,7 +1280,7 @@ class GlobalSearch extends Component
public function loadEnvironments()
{
$this->loadingEnvironments = true;
- $project = Project::where('uuid', $this->selectedProjectUuid)->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $this->selectedProjectUuid)->first();
if (! $project) {
$this->loadingEnvironments = false;
diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php
index b914fbd94..ab3884320 100644
--- a/app/Livewire/Notifications/Discord.php
+++ b/app/Livewire/Notifications/Discord.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\DiscordNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
+use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -20,7 +21,7 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $discordEnabled = false;
- #[Validate(['url', 'nullable'])]
+ #[Validate(['nullable', new SafeWebhookUrl])]
public ?string $discordWebhookUrl = null;
#[Validate(['boolean'])]
diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 847f10765..364163ff8 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -42,7 +42,7 @@ class Email extends Component
public ?string $smtpHost = null;
#[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])]
- public ?int $smtpPort = null;
+ public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
public ?string $smtpEncryption = null;
@@ -54,7 +54,7 @@ class Email extends Component
public ?string $smtpPassword = null;
#[Validate(['nullable', 'numeric'])]
- public ?int $smtpTimeout = null;
+ public ?string $smtpTimeout = null;
#[Validate(['boolean'])]
public bool $resendEnabled = false;
diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php
index fa8c97ae9..f870b3986 100644
--- a/app/Livewire/Notifications/Slack.php
+++ b/app/Livewire/Notifications/Slack.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
+use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -25,7 +26,7 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $slackEnabled = false;
- #[Validate(['url', 'nullable'])]
+ #[Validate(['nullable', new SafeWebhookUrl])]
public ?string $slackWebhookUrl = null;
#[Validate(['boolean'])]
diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php
index 8af70c6eb..630d422a9 100644
--- a/app/Livewire/Notifications/Webhook.php
+++ b/app/Livewire/Notifications/Webhook.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
+use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -20,7 +21,7 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $webhookEnabled = false;
- #[Validate(['url', 'nullable'])]
+ #[Validate(['nullable', new SafeWebhookUrl])]
public ?string $webhookUrl = null;
#[Validate(['boolean'])]
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 5c186af70..25ce82eb0 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -146,15 +146,15 @@ class General extends Component
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
- 'installCommand' => 'nullable',
- 'buildCommand' => 'nullable',
- 'startCommand' => 'nullable',
+ 'installCommand' => ValidationPatterns::shellSafeCommandRules(),
+ 'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
+ 'startCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildPack' => 'required',
'staticImage' => 'required',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
- 'portsExposes' => 'required',
- 'portsMappings' => 'nullable',
+ 'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'],
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable',
@@ -200,6 +200,9 @@ class General extends Component
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
@@ -209,6 +212,8 @@ class General extends Component
'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory field is required.',
'portsExposes.required' => 'The Exposed Ports field is required.',
+ 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
+ ...ValidationPatterns::portMappingMessages(),
'isStatic.required' => 'The Static setting is required.',
'isStatic.boolean' => 'The Static setting must be true or false.',
'isSpa.required' => 'The SPA setting is required.',
@@ -732,6 +737,7 @@ class General extends Component
$this->authorize('update', $this->application);
try {
+ $this->application->redirect = $this->redirect;
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.
Please add www to your domain list and as an A DNS record (if applicable).');
@@ -752,6 +758,12 @@ class General extends Component
$this->authorize('update', $this->application);
$this->resetErrorBag();
+
+ $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString();
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
+
$this->validate();
$oldPortsExposes = $this->application->ports_exposes;
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index 41f352c14..c887e9b83 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -35,8 +35,17 @@ class Previews extends Component
public array $previewFqdns = [];
+ public array $previewDockerTags = [];
+
+ public ?int $manualPullRequestId = null;
+
+ public ?string $manualDockerTag = null;
+
protected $rules = [
'previewFqdns.*' => 'string|nullable',
+ 'previewDockerTags.*' => 'string|nullable',
+ 'manualPullRequestId' => 'integer|min:1|nullable',
+ 'manualDockerTag' => 'string|nullable',
];
public function mount()
@@ -53,12 +62,17 @@ class Previews extends Component
$preview = $this->application->previews->get($key);
if ($preview) {
$preview->fqdn = $fqdn;
+ if ($this->application->build_pack === 'dockerimage') {
+ $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
+ }
}
}
} else {
$this->previewFqdns = [];
+ $this->previewDockerTags = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewFqdns[$key] = $preview->fqdn;
+ $this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
}
}
}
@@ -174,7 +188,7 @@ class Previews extends Component
}
}
- public function add(int $pull_request_id, ?string $pull_request_html_url = null)
+ public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
try {
$this->authorize('update', $this->application);
@@ -195,13 +209,18 @@ class Previews extends Component
} else {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
- if (! $found && ! is_null($pull_request_html_url)) {
+ if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
$found = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
- 'pull_request_html_url' => $pull_request_html_url,
+ 'pull_request_html_url' => $pull_request_html_url ?? '',
+ 'docker_registry_image_tag' => $docker_registry_image_tag,
]);
}
+ if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
+ $found->docker_registry_image_tag = $docker_registry_image_tag;
+ $found->save();
+ }
$found->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
@@ -217,37 +236,50 @@ class Previews extends Component
{
$this->authorize('deploy', $this->application);
- $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
+ $dockerRegistryImageTag = null;
+ if ($this->application->build_pack === 'dockerimage') {
+ $dockerRegistryImageTag = $this->application->previews()
+ ->where('pull_request_id', $pull_request_id)
+ ->value('docker_registry_image_tag');
+ }
+
+ $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
}
- public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
+ public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
- $this->add($pull_request_id, $pull_request_html_url);
- $this->deploy($pull_request_id, $pull_request_html_url);
+ $this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
+ $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
}
- public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
+ public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
try {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
- if (! $found && ! is_null($pull_request_html_url)) {
- ApplicationPreview::create([
+ if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
+ $found = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
- 'pull_request_html_url' => $pull_request_html_url,
+ 'pull_request_html_url' => $pull_request_html_url ?? '',
+ 'docker_registry_image_tag' => $docker_registry_image_tag,
]);
}
+ if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
+ $found->docker_registry_image_tag = $docker_registry_image_tag;
+ $found->save();
+ }
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deployment_uuid,
force_rebuild: $force_rebuild,
pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null,
+ docker_registry_image_tag: $docker_registry_image_tag,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
@@ -277,6 +309,32 @@ class Previews extends Component
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}
+ public function addDockerImagePreview()
+ {
+ $this->authorize('deploy', $this->application);
+ $this->validateOnly('manualPullRequestId');
+ $this->validateOnly('manualDockerTag');
+
+ if ($this->application->build_pack !== 'dockerimage') {
+ $this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.');
+
+ return;
+ }
+
+ if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) {
+ $this->dispatch('error', 'Both pull request id and docker tag are required.');
+
+ return;
+ }
+
+ $dockerTag = str($this->manualDockerTag)->trim()->value();
+
+ $this->add_and_deploy($this->manualPullRequestId, null, $dockerTag);
+
+ $this->manualPullRequestId = null;
+ $this->manualDockerTag = null;
+ }
+
private function stopContainers(array $containers, $server)
{
$containersToStop = collect($containers)->pluck('Names')->toArray();
diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php
index 3b3e42619..644753c83 100644
--- a/app/Livewire/Project/CloneMe.php
+++ b/app/Livewire/Project/CloneMe.php
@@ -54,7 +54,7 @@ class CloneMe extends Component
public function mount($project_uuid)
{
$this->project_uuid = $project_uuid;
- $this->project = Project::where('uuid', $project_uuid)->firstOrFail();
+ $this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first();
$this->project_id = $this->project->id;
$this->servers = currentTeam()
@@ -187,6 +187,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $newDatabase->id,
@@ -298,9 +299,9 @@ class CloneMe extends Component
}
foreach ($newService->applications() as $application) {
- $application->update([
+ $application->fill([
'status' => 'exited',
- ]);
+ ])->save();
$persistentVolumes = $application->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
@@ -315,6 +316,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $application->id,
@@ -352,9 +354,9 @@ class CloneMe extends Component
}
foreach ($newService->databases() as $database) {
- $database->update([
+ $database->fill([
'status' => 'exited',
- ]);
+ ])->save();
$persistentVolumes = $database->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
@@ -369,6 +371,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $database->id,
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index 0fff2bd03..a18022882 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -76,7 +76,7 @@ class BackupEdit extends Component
public bool $dumpAll = false;
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
- public int $timeout = 3600;
+ public int|string $timeout = 3600;
public function mount()
{
diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index 9de75c1c5..e06629d10 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -34,9 +34,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null;
@@ -79,9 +79,9 @@ class General extends Component
'clickhouseAdminUser' => 'required|string',
'clickhouseAdminPassword' => 'required|string',
'image' => 'required|string',
- 'portsMappings' => 'nullable|string',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
@@ -94,6 +94,7 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'clickhouseAdminUser.required' => 'The Admin User field is required.',
'clickhouseAdminUser.string' => 'The Admin User must be a string.',
@@ -102,6 +103,8 @@ class General extends Component
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
@@ -119,8 +122,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
@@ -207,6 +210,9 @@ class General extends Component
try {
$this->authorize('update', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index d35e57a9d..5176f5ff9 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -34,9 +34,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null;
@@ -57,7 +57,8 @@ class General extends Component
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@@ -90,9 +91,9 @@ class General extends Component
'description' => ValidationPatterns::descriptionRules(),
'dragonflyPassword' => 'required|string',
'image' => 'required|string',
- 'portsMappings' => 'nullable|string',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
@@ -106,12 +107,15 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'dragonflyPassword.required' => 'The Dragonfly Password field is required.',
'dragonflyPassword.string' => 'The Dragonfly Password must be a string.',
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
@@ -128,8 +132,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
@@ -217,6 +221,9 @@ class General extends Component
try {
$this->authorize('update', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
@@ -276,8 +283,8 @@ class General extends Component
}
SslHelper::generateSslCertificate(
- commonName: $existingCert->commonName,
- subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
+ commonName: $existingCert->common_name,
+ subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
@@ -293,4 +300,10 @@ class General extends Component
handleError($e, $this);
}
}
+
+ public function refresh(): void
+ {
+ $this->database->refresh();
+ $this->syncData();
+ }
}
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index adb4ccb5f..b50f196a8 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -36,9 +36,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null;
@@ -59,7 +59,8 @@ class General extends Component
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@@ -93,9 +94,9 @@ class General extends Component
'keydbConf' => 'nullable|string',
'keydbPassword' => 'required|string',
'image' => 'required|string',
- 'portsMappings' => 'nullable|string',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
@@ -111,12 +112,15 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'keydbPassword.required' => 'The KeyDB Password field is required.',
'keydbPassword.string' => 'The KeyDB Password must be a string.',
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
@@ -134,8 +138,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
@@ -224,6 +228,9 @@ class General extends Component
try {
$this->authorize('manageEnvironment', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
@@ -269,9 +276,20 @@ class General extends Component
->where('is_ca_certificate', true)
->first();
+ if (! $caCert) {
+ $this->server->generateCaCertificate();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
SslHelper::generateSslCertificate(
- commonName: $existingCert->commonName,
- subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
+ commonName: $existingCert->common_name,
+ subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
@@ -287,4 +305,10 @@ class General extends Component
handleError($e, $this);
}
}
+
+ public function refresh(): void
+ {
+ $this->database->refresh();
+ $this->syncData();
+ }
}
diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php
index 14240c82d..9a1a8bd68 100644
--- a/app/Livewire/Project/Database/Mariadb/General.php
+++ b/app/Livewire/Project/Database/Mariadb/General.php
@@ -42,9 +42,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
@@ -61,9 +61,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
+ $teamId = Auth::user()->currentTeam()->id;
return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@@ -78,9 +80,9 @@ class General extends Component
'mariadbDatabase' => 'required',
'mariadbConf' => 'nullable',
'image' => 'required',
- 'portsMappings' => 'nullable',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
@@ -92,6 +94,7 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'mariadbRootPassword.required' => 'The Root Password field is required.',
@@ -100,6 +103,8 @@ class General extends Component
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
@@ -159,8 +164,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -213,6 +218,9 @@ class General extends Component
try {
$this->authorize('update', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
@@ -289,6 +297,17 @@ class General extends Component
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ if (! $caCert) {
+ $this->server->generateCaCertificate();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php
index 11419ec71..a21de744a 100644
--- a/app/Livewire/Project/Database/Mongodb/General.php
+++ b/app/Livewire/Project/Database/Mongodb/General.php
@@ -40,9 +40,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
@@ -61,9 +61,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
+ $teamId = Auth::user()->currentTeam()->id;
return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@@ -77,9 +79,9 @@ class General extends Component
'mongoInitdbRootPassword' => 'required',
'mongoInitdbDatabase' => 'required',
'image' => 'required',
- 'portsMappings' => 'nullable',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
@@ -92,6 +94,7 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
@@ -99,6 +102,8 @@ class General extends Component
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'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.',
@@ -158,8 +163,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -213,6 +218,9 @@ class General extends Component
try {
$this->authorize('update', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
@@ -297,6 +305,17 @@ class General extends Component
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ if (! $caCert) {
+ $this->server->generateCaCertificate();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index 4f0f5eb19..cacb4ac49 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -42,9 +42,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
@@ -63,9 +63,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
+ $teamId = Auth::user()->currentTeam()->id;
return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@@ -80,9 +82,9 @@ class General extends Component
'mysqlDatabase' => 'required',
'mysqlConf' => 'nullable',
'image' => 'required',
- 'portsMappings' => 'nullable',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
@@ -95,6 +97,7 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'mysqlRootPassword.required' => 'The Root Password field is required.',
@@ -103,6 +106,8 @@ class General extends Component
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'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.',
@@ -164,8 +169,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -220,6 +225,9 @@ class General extends Component
try {
$this->authorize('update', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
@@ -301,6 +309,17 @@ class General extends Component
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ if (! $caCert) {
+ $this->server->generateCaCertificate();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 4e044672b..22e350683 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -46,9 +46,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
@@ -71,9 +71,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
+ $teamId = Auth::user()->currentTeam()->id;
return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'save_init_script',
'delete_init_script',
];
@@ -92,9 +94,9 @@ class General extends Component
'postgresConf' => 'nullable',
'initScripts' => 'nullable',
'image' => 'required',
- 'portsMappings' => 'nullable',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
@@ -107,6 +109,7 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'postgresUser.required' => 'The Postgres User field is required.',
@@ -114,6 +117,8 @@ class General extends Component
'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.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'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.',
@@ -179,8 +184,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -264,6 +269,17 @@ class General extends Component
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ if (! $caCert) {
+ $this->server->generateCaCertificate();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
@@ -456,6 +472,9 @@ class General extends Component
try {
$this->authorize('update', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
@@ -471,4 +490,10 @@ class General extends Component
}
}
}
+
+ public function refresh(): void
+ {
+ $this->database->refresh();
+ $this->syncData();
+ }
}
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index ebe2f3ba0..3c32a6192 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -34,9 +34,9 @@ class General extends Component
public ?bool $isPublic = null;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
@@ -59,9 +59,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
+ $teamId = Auth::user()->currentTeam()->id;
return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
+ "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'envsUpdated' => 'refresh',
];
}
@@ -73,9 +75,9 @@ class General extends Component
'description' => ValidationPatterns::descriptionRules(),
'redisConf' => 'nullable',
'image' => 'required',
- 'portsMappings' => 'nullable',
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
@@ -89,10 +91,13 @@ class General extends Component
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
+ 'publicPort.min' => 'The Public Port must be at least 1.',
+ 'publicPort.max' => 'The Public Port must not exceed 65535.',
'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.',
@@ -148,8 +153,8 @@ class General extends Component
$this->database->image = $this->image;
$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->public_port = $this->publicPort ?: null;
+ $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -201,6 +206,9 @@ class General extends Component
try {
$this->authorize('manageEnvironment', $this->database);
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
$this->syncData(true);
if (version_compare($this->redisVersion, '6.0', '>=')) {
@@ -282,9 +290,20 @@ class General extends Component
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ if (! $caCert) {
+ $this->server->generateCaCertificate();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
SslHelper::generateSslCertificate(
- commonName: $existingCert->commonName,
- subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
+ commonName: $existingCert->common_name,
+ subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php
index a018046fd..d95041c2d 100644
--- a/app/Livewire/Project/DeleteProject.php
+++ b/app/Livewire/Project/DeleteProject.php
@@ -21,7 +21,7 @@ class DeleteProject extends Component
public function mount()
{
$this->parameters = get_route_parameters();
- $this->projectName = Project::findOrFail($this->project_id)->name;
+ $this->projectName = Project::ownedByCurrentTeam()->findOrFail($this->project_id)->name;
}
public function delete()
@@ -29,7 +29,7 @@ class DeleteProject extends Component
$this->validate([
'project_id' => 'required|int',
]);
- $project = Project::findOrFail($this->project_id);
+ $project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
$this->authorize('delete', $project);
if ($project->isEmpty()) {
diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php
index 634a012c0..2b92902c6 100644
--- a/app/Livewire/Project/New/DockerCompose.php
+++ b/app/Livewire/Project/New/DockerCompose.php
@@ -41,8 +41,8 @@ class DockerCompose extends Component
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
- $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
- $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
+ $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 8aff83153..268333d07 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -121,8 +121,8 @@ class DockerImage extends Component
}
$destination_class = $destination->getMorphClass();
- $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
- $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
+ $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 61ae0e151..0222008b0 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
+use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
@@ -98,6 +99,8 @@ class GithubPrivateRepository extends Component
public function loadRepositories($github_app_id)
{
$this->repositories = collect();
+ $this->branches = collect();
+ $this->total_branches_count = 0;
$this->page = 1;
$this->selected_github_app_id = $github_app_id;
$this->github_app = GithubApp::where('id', $github_app_id)->first();
@@ -168,7 +171,7 @@ class GithubPrivateRepository extends Component
'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(),
+ 'docker_compose_location' => ValidationPatterns::filePathRules(),
]);
if ($validator->fails()) {
@@ -185,8 +188,8 @@ class GithubPrivateRepository extends Component
}
$destination_class = $destination->getMorphClass();
- $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
- $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
+ $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$application = Application::create([
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index e46ad7d78..f8642d6fc 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -11,6 +11,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
+use App\Support\ValidationPatterns;
use Illuminate\Support\Str;
use Livewire\Component;
use Spatie\Url\Url;
@@ -66,7 +67,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
- 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
+ 'docker_compose_location' => ValidationPatterns::filePathRules(),
];
}
@@ -144,8 +145,8 @@ class GithubPrivateRepositoryDeployKey extends Component
// Note: git_repository has already been validated and transformed in get_git_source()
// It may now be in SSH format (git@host:repo.git) which is valid for deploy keys
- $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
- $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
+ $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index 3df31a6a3..62ac7ec0d 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -11,6 +11,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
+use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Livewire\Component;
use Spatie\Url\Url;
@@ -72,7 +73,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
- 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
+ 'docker_compose_location' => ValidationPatterns::filePathRules(),
'git_branch' => ['required', 'string', new ValidGitBranch],
];
}
@@ -233,7 +234,7 @@ class PublicGitRepository extends Component
return;
}
- if ($this->git_source->getMorphClass() === \App\Models\GithubApp::class) {
+ if ($this->git_source->getMorphClass() === GithubApp::class) {
['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$this->git_branch}");
$this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s');
$this->branchFound = true;
@@ -278,8 +279,8 @@ class PublicGitRepository extends Component
}
$destination_class = $destination->getMorphClass();
- $project = Project::where('uuid', $project_uuid)->first();
- $environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
+ $environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
$server = $destination->server;
diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php
index c5dc13987..165e4b59e 100644
--- a/app/Livewire/Project/New/Select.php
+++ b/app/Livewire/Project/New/Select.php
@@ -65,7 +65,7 @@ class Select extends Component
$this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432';
}
$projectUuid = data_get($this->parameters, 'project_uuid');
- $project = Project::whereUuid($projectUuid)->firstOrFail();
+ $project = Project::ownedByCurrentTeam()->whereUuid($projectUuid)->firstOrFail();
$this->environments = $project->environments;
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
@@ -79,7 +79,7 @@ class Select extends Component
$this->type = $queryType;
$this->server_id = $queryServerId;
$this->destination_uuid = $queryDestination;
- $this->server = Server::find($queryServerId);
+ $this->server = Server::ownedByCurrentTeam()->find($queryServerId);
$this->current_step = 'select-postgresql-type';
}
} catch (\Exception $e) {
diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php
index 9cc4fbbe2..1073157e6 100644
--- a/app/Livewire/Project/New/SimpleDockerfile.php
+++ b/app/Livewire/Project/New/SimpleDockerfile.php
@@ -45,8 +45,8 @@ CMD ["nginx", "-g", "daemon off;"]
}
$destination_class = $destination->getMorphClass();
- $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
- $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+ $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
+ $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$port = get_port_from_dockerfile($this->dockerfile);
if (! $port) {
diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php
index c77a3a516..cb2d977bc 100644
--- a/app/Livewire/Project/Service/Index.php
+++ b/app/Livewire/Project/Service/Index.php
@@ -51,9 +51,9 @@ class Index extends Component
public bool $excludeFromStatus = false;
- public ?int $publicPort = null;
+ public mixed $publicPort = null;
- public ?int $publicPortTimeout = 3600;
+ public mixed $publicPortTimeout = 3600;
public bool $isPublic = false;
@@ -91,7 +91,7 @@ class Index extends Component
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
- 'publicPort' => 'nullable|integer',
+ 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
@@ -160,8 +160,8 @@ class Index extends Component
$this->serviceDatabase->description = $this->description;
$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->public_port = $this->publicPort ?: null;
+ $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout ?: null;
$this->serviceDatabase->is_public = $this->isPublic;
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index e896f060a..433c2b13c 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -103,10 +104,10 @@ class Storage extends Component
$this->authorize('update', $this->resource);
$this->validate([
- 'name' => 'required|string',
+ 'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
- ]);
+ ], ValidationPatterns::volumeNameMessages());
$name = $this->resource->uuid.'-'.$this->name;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index 73d5393b0..c51b27b6a 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -71,6 +71,7 @@ class Add extends Component
'team' => [],
'project' => [],
'environment' => [],
+ 'server' => [],
];
// Early return if no team
@@ -126,6 +127,66 @@ class Add extends Component
}
}
+ // Get server variables
+ $serverUuid = data_get($this->parameters, 'server_uuid');
+ if ($serverUuid) {
+ // If we have a specific server_uuid, show variables for that server
+ $server = \App\Models\Server::where('team_id', $team->id)
+ ->where('uuid', $serverUuid)
+ ->first();
+
+ if ($server) {
+ try {
+ $this->authorize('view', $server);
+ $result['server'] = $server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ } else {
+ // For application environment variables, try to use the application's destination server
+ $applicationUuid = data_get($this->parameters, 'application_uuid');
+ if ($applicationUuid) {
+ $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $applicationUuid)
+ ->with('destination.server')
+ ->first();
+
+ if ($application && $application->destination && $application->destination->server) {
+ try {
+ $this->authorize('view', $application->destination->server);
+ $result['server'] = $application->destination->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ } else {
+ // For service environment variables, try to use the service's server
+ $serviceUuid = data_get($this->parameters, 'service_uuid');
+ if ($serviceUuid) {
+ $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $serviceUuid)
+ ->with('server')
+ ->first();
+
+ if ($service && $service->server) {
+ try {
+ $this->authorize('view', $service->server);
+ $result['server'] = $service->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
+ }
+ }
+
return $result;
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index c567d96aa..4e8521f27 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -219,6 +219,7 @@ class Show extends Component
'team' => [],
'project' => [],
'environment' => [],
+ 'server' => [],
];
// Early return if no team
@@ -274,6 +275,66 @@ class Show extends Component
}
}
+ // Get server variables
+ $serverUuid = data_get($this->parameters, 'server_uuid');
+ if ($serverUuid) {
+ // If we have a specific server_uuid, show variables for that server
+ $server = \App\Models\Server::where('team_id', $team->id)
+ ->where('uuid', $serverUuid)
+ ->first();
+
+ if ($server) {
+ try {
+ $this->authorize('view', $server);
+ $result['server'] = $server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ } else {
+ // For application environment variables, try to use the application's destination server
+ $applicationUuid = data_get($this->parameters, 'application_uuid');
+ if ($applicationUuid) {
+ $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $applicationUuid)
+ ->with('destination.server')
+ ->first();
+
+ if ($application && $application->destination && $application->destination->server) {
+ try {
+ $this->authorize('view', $application->destination->server);
+ $result['server'] = $application->destination->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ } else {
+ // For service environment variables, try to use the service's server
+ $serviceUuid = data_get($this->parameters, 'service_uuid');
+ if ($serviceUuid) {
+ $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ ->where('uuid', $serviceUuid)
+ ->with('server')
+ ->first();
+
+ if ($service && $service->server) {
+ try {
+ $this->authorize('view', $service->server);
+ $result['server'] = $service->server->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
+ }
+ }
+
return $result;
}
diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php
index 22605e1bb..d0121bdc5 100644
--- a/app/Livewire/Project/Shared/GetLogs.php
+++ b/app/Livewire/Project/Shared/GetLogs.php
@@ -16,7 +16,9 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Process;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class GetLogs extends Component
@@ -29,12 +31,16 @@ class GetLogs extends Component
public string $errors = '';
+ #[Locked]
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|null $resource = null;
+ #[Locked]
public ServiceApplication|ServiceDatabase|null $servicesubtype = null;
+ #[Locked]
public Server $server;
+ #[Locked]
public ?string $container = null;
public ?string $displayName = null;
@@ -54,7 +60,7 @@ class GetLogs extends Component
public function mount()
{
if (! is_null($this->resource)) {
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource->getMorphClass() === Application::class) {
$this->showTimeStamps = $this->resource->settings->is_include_timestamps;
} else {
if ($this->servicesubtype) {
@@ -63,7 +69,7 @@ class GetLogs extends Component
$this->showTimeStamps = $this->resource->is_include_timestamps;
}
}
- if ($this->resource?->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource?->getMorphClass() === Application::class) {
if (str($this->container)->contains('-pr-')) {
$this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value();
}
@@ -74,11 +80,11 @@ class GetLogs extends Component
public function instantSave()
{
if (! is_null($this->resource)) {
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource->getMorphClass() === Application::class) {
$this->resource->settings->is_include_timestamps = $this->showTimeStamps;
$this->resource->settings->save();
}
- if ($this->resource->getMorphClass() === \App\Models\Service::class) {
+ if ($this->resource->getMorphClass() === Service::class) {
$serviceName = str($this->container)->beforeLast('-')->value();
$subType = $this->resource->applications()->where('name', $serviceName)->first();
if ($subType) {
@@ -118,10 +124,20 @@ class GetLogs extends Component
public function getLogs($refresh = false)
{
+ if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
+ $this->outputs = 'Unauthorized.';
+
+ return;
+ }
if (! $this->server->isFunctional()) {
return;
}
- if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) {
+ if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->outputs = 'Invalid container name.';
+
+ return;
+ }
+ if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) {
return;
}
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
@@ -194,9 +210,15 @@ class GetLogs extends Component
public function downloadAllLogs(): string
{
+ if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
+ return '';
+ }
if (! $this->server->isFunctional() || ! $this->container) {
return '';
}
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ return '';
+ }
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php
index 0b3840289..8a14dc10c 100644
--- a/app/Livewire/Project/Shared/ResourceLimits.php
+++ b/app/Livewire/Project/Shared/ResourceLimits.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Shared;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Validation\ValidationException;
use Livewire\Component;
class ResourceLimits extends Component
@@ -16,24 +17,24 @@ class ResourceLimits extends Component
public ?string $limitsCpuset = null;
- public ?int $limitsCpuShares = null;
+ public mixed $limitsCpuShares = null;
public string $limitsMemory;
public string $limitsMemorySwap;
- public int $limitsMemorySwappiness;
+ public mixed $limitsMemorySwappiness = 0;
public string $limitsMemoryReservation;
protected $rules = [
- 'limitsMemory' => 'required|string',
- 'limitsMemorySwap' => 'required|string',
+ 'limitsMemory' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'],
+ 'limitsMemorySwap' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'],
'limitsMemorySwappiness' => 'required|integer|min:0|max:100',
- 'limitsMemoryReservation' => 'required|string',
- 'limitsCpus' => 'nullable',
- 'limitsCpuset' => 'nullable',
- 'limitsCpuShares' => 'nullable',
+ 'limitsMemoryReservation' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'],
+ 'limitsCpus' => ['nullable', 'regex:/^\d*\.?\d+$/'],
+ 'limitsCpuset' => ['nullable', 'regex:/^\d+([,-]\d+)*$/'],
+ 'limitsCpuShares' => 'nullable|integer|min:0',
];
protected $validationAttributes = [
@@ -46,6 +47,19 @@ class ResourceLimits extends Component
'limitsCpuShares' => 'cpu shares',
];
+ protected $messages = [
+ 'limitsMemory.regex' => 'Maximum Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.',
+ 'limitsMemorySwap.regex' => 'Maximum Swap Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.',
+ 'limitsMemoryReservation.regex' => 'Soft Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.',
+ 'limitsCpus.regex' => 'Number of CPUs must be a number (integer or decimal). Example: 0.5, 2.',
+ 'limitsCpuset.regex' => 'CPU sets must be a comma-separated list of CPU numbers or ranges. Example: 0-2 or 0,1,3.',
+ 'limitsMemorySwappiness.integer' => 'Swappiness must be a whole number between 0 and 100.',
+ 'limitsMemorySwappiness.min' => 'Swappiness must be between 0 and 100.',
+ 'limitsMemorySwappiness.max' => 'Swappiness must be between 0 and 100.',
+ 'limitsCpuShares.integer' => 'CPU Weight must be a whole number.',
+ 'limitsCpuShares.min' => 'CPU Weight must be a positive number.',
+ ];
+
/**
* Sync data between component properties and model
*
@@ -57,10 +71,10 @@ class ResourceLimits extends Component
// Sync TO model (before save)
$this->resource->limits_cpus = $this->limitsCpus;
$this->resource->limits_cpuset = $this->limitsCpuset;
- $this->resource->limits_cpu_shares = $this->limitsCpuShares;
+ $this->resource->limits_cpu_shares = (int) $this->limitsCpuShares;
$this->resource->limits_memory = $this->limitsMemory;
$this->resource->limits_memory_swap = $this->limitsMemorySwap;
- $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness;
+ $this->resource->limits_memory_swappiness = (int) $this->limitsMemorySwappiness;
$this->resource->limits_memory_reservation = $this->limitsMemoryReservation;
} else {
// Sync FROM model (on load/refresh)
@@ -91,7 +105,7 @@ class ResourceLimits extends Component
if (! $this->limitsMemorySwap) {
$this->limitsMemorySwap = '0';
}
- if (is_null($this->limitsMemorySwappiness)) {
+ if ($this->limitsMemorySwappiness === '' || is_null($this->limitsMemorySwappiness)) {
$this->limitsMemorySwappiness = 60;
}
if (! $this->limitsMemoryReservation) {
@@ -103,7 +117,7 @@ class ResourceLimits extends Component
if ($this->limitsCpuset === '') {
$this->limitsCpuset = null;
}
- if (is_null($this->limitsCpuShares)) {
+ if ($this->limitsCpuShares === '' || is_null($this->limitsCpuShares)) {
$this->limitsCpuShares = 1024;
}
@@ -112,6 +126,12 @@ class ResourceLimits extends Component
$this->syncData(true);
$this->resource->save();
$this->dispatch('success', 'Resource limits updated.');
+ } catch (ValidationException $e) {
+ foreach ($e->validator->errors()->all() as $message) {
+ $this->dispatch('error', $message);
+ }
+
+ return;
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index e769e4bcb..f4813dd4c 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -7,9 +7,18 @@ use App\Actions\Database\StopDatabase;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Jobs\VolumeCloneJob;
+use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
+use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
+use App\Models\StandaloneDragonfly;
+use App\Models\StandaloneKeydb;
+use App\Models\StandaloneMariadb;
+use App\Models\StandaloneMongodb;
+use App\Models\StandaloneMysql;
+use App\Models\StandalonePostgresql;
+use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -60,7 +69,7 @@ class ResourceOperations extends Component
$uuid = (string) new Cuid2;
$server = $new_destination->server;
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
$route = route('project.application.configuration', [
@@ -71,14 +80,14 @@ class ResourceOperations extends Component
return redirect()->to($route);
} elseif (
- $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
+ $this->resource->getMorphClass() === StandalonePostgresql::class ||
+ $this->resource->getMorphClass() === StandaloneMongodb::class ||
+ $this->resource->getMorphClass() === StandaloneMysql::class ||
+ $this->resource->getMorphClass() === StandaloneMariadb::class ||
+ $this->resource->getMorphClass() === StandaloneRedis::class ||
+ $this->resource->getMorphClass() === StandaloneKeydb::class ||
+ $this->resource->getMorphClass() === StandaloneDragonfly::class ||
+ $this->resource->getMorphClass() === StandaloneClickhouse::class
) {
$uuid = (string) new Cuid2;
$new_resource = $this->resource->replicate([
@@ -133,6 +142,7 @@ class ResourceOperations extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $new_resource->id,
@@ -254,9 +264,9 @@ class ResourceOperations extends Component
}
foreach ($new_resource->applications() as $application) {
- $application->update([
+ $application->fill([
'status' => 'exited',
- ]);
+ ])->save();
$persistentVolumes = $application->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
@@ -271,6 +281,7 @@ class ResourceOperations extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $application->id,
@@ -296,9 +307,9 @@ class ResourceOperations extends Component
}
foreach ($new_resource->databases() as $database) {
- $database->update([
+ $database->fill([
'status' => 'exited',
- ]);
+ ])->save();
$persistentVolumes = $database->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
@@ -313,6 +324,7 @@ class ResourceOperations extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $database->id,
@@ -354,9 +366,9 @@ class ResourceOperations extends Component
try {
$this->authorize('update', $this->resource);
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
- $this->resource->update([
+ $this->resource->fill([
'environment_id' => $environment_id,
- ]);
+ ])->save();
if ($this->resource->type() === 'application') {
$route = route('project.application.configuration', [
'project_uuid' => $new_environment->project->uuid,
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php
index 02c13a66c..882737f09 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Show.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php
@@ -52,9 +52,15 @@ class Show extends Component
#[Locked]
public string $task_uuid;
- public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null)
+ public function mount()
{
try {
+ $task_uuid = request()->route('task_uuid');
+ $project_uuid = request()->route('project_uuid');
+ $environment_uuid = request()->route('environment_uuid');
+ $application_uuid = request()->route('application_uuid');
+ $service_uuid = request()->route('service_uuid');
+
$this->task_uuid = $task_uuid;
if ($application_uuid) {
$this->type = 'application';
@@ -105,6 +111,19 @@ class Show extends Component
}
}
+ public function toggleEnabled()
+ {
+ try {
+ $this->authorize('update', $this->resource);
+ $this->isEnabled = ! $this->isEnabled;
+ $this->task->enabled = $this->isEnabled;
+ $this->task->save();
+ $this->dispatch('success', $this->isEnabled ? 'Scheduled task enabled.' : 'Scheduled task disabled.');
+ } catch (\Exception $e) {
+ return handleError($e);
+ }
+ }
+
public function instantSave()
{
try {
diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php
index dba1b4903..b39da5e5a 100644
--- a/app/Livewire/Server/Advanced.php
+++ b/app/Livewire/Server/Advanced.php
@@ -15,17 +15,17 @@ class Advanced extends Component
#[Validate(['string'])]
public string $serverDiskUsageCheckFrequency = '0 23 * * *';
- #[Validate(['integer', 'min:1', 'max:99'])]
- public int $serverDiskUsageNotificationThreshold = 50;
+ #[Validate(['required', 'integer', 'min:1', 'max:99'])]
+ public int|string $serverDiskUsageNotificationThreshold = 50;
- #[Validate(['integer', 'min:1'])]
- public int $concurrentBuilds = 1;
+ #[Validate(['required', 'integer', 'min:1'])]
+ public int|string $concurrentBuilds = 1;
- #[Validate(['integer', 'min:1'])]
- public int $dynamicTimeout = 1;
+ #[Validate(['required', 'integer', 'min:1'])]
+ public int|string $dynamicTimeout = 1;
- #[Validate(['integer', 'min:1'])]
- public int $deploymentQueueLimit = 25;
+ #[Validate(['required', 'integer', 'min:1'])]
+ public int|string $deploymentQueueLimit = 25;
public function mount(string $server_uuid)
{
diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php
index fd55717fa..810b95ed4 100644
--- a/app/Livewire/Server/PrivateKey/Show.php
+++ b/app/Livewire/Server/PrivateKey/Show.php
@@ -63,7 +63,8 @@ class Show extends Component
$this->dispatch('success', 'Server is reachable.');
$this->dispatch('refreshServerShow');
} else {
- $this->dispatch('error', 'Server is not reachable.
Error: '.$sanitizedError);
return;
}
diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php
index d5f30fca0..c2d8205ef 100644
--- a/app/Livewire/Server/Proxy.php
+++ b/app/Livewire/Server/Proxy.php
@@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
+use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -41,9 +42,13 @@ class Proxy extends Component
];
}
- protected $rules = [
- 'generateExactLabels' => 'required|boolean',
- ];
+ protected function rules()
+ {
+ return [
+ 'generateExactLabels' => 'required|boolean',
+ 'redirectUrl' => ['nullable', new SafeExternalUrl],
+ ];
+ }
public function mount()
{
@@ -147,6 +152,7 @@ class Proxy extends Component
{
try {
$this->authorize('update', $this->server);
+ $this->validate();
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php
index dff379ae1..a4b35891b 100644
--- a/app/Livewire/Server/Sentinel.php
+++ b/app/Livewire/Server/Sentinel.php
@@ -25,13 +25,13 @@ class Sentinel extends Component
public ?string $sentinelUpdatedAt = null;
#[Validate(['required', 'integer', 'min:1'])]
- public int $sentinelMetricsRefreshRateSeconds;
+ public int|string $sentinelMetricsRefreshRateSeconds;
#[Validate(['required', 'integer', 'min:1'])]
- public int $sentinelMetricsHistoryDays;
+ public int|string $sentinelMetricsHistoryDays;
#[Validate(['required', 'integer', 'min:10'])]
- public int $sentinelPushIntervalSeconds;
+ public int|string $sentinelPushIntervalSeconds;
#[Validate(['nullable', 'url'])]
public ?string $sentinelCustomUrl = null;
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 198d823b9..59ca4cd36 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -89,7 +89,8 @@ class ValidateAndInstall extends Component
$this->authorize('update', $this->server);
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Error: '.$error.'
';
+ $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
+ $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Check if you used the right extension (.yaml or .yml) in the compose file name.");
+ throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
if ($this->deploymentType() === 'deploy_key') {
- throw new \RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.');
+ throw new RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.');
}
- throw new \RuntimeException('Repository does not exist. Please check your repository URL and try again.');
+ throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
}
- throw new \RuntimeException($e->getMessage());
+ throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally {
// Cleanup only - restoration happens in catch block
$commands = collect([
@@ -1793,7 +1886,7 @@ class Application extends BaseModel
$this->base_directory = $initialBaseDirectory;
$this->save();
- throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
+ throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php
index 34257e7a7..67f28523c 100644
--- a/app/Models/ApplicationDeploymentQueue.php
+++ b/app/Models/ApplicationDeploymentQueue.php
@@ -16,6 +16,7 @@ use OpenApi\Attributes as OA;
'application_id' => ['type' => 'string'],
'deployment_uuid' => ['type' => 'string'],
'pull_request_id' => ['type' => 'integer'],
+ 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
'force_rebuild' => ['type' => 'boolean'],
'commit' => ['type' => 'string'],
'status' => ['type' => 'string'],
@@ -39,9 +40,36 @@ use OpenApi\Attributes as OA;
)]
class ApplicationDeploymentQueue extends Model
{
- protected $guarded = [];
+ protected $fillable = [
+ 'application_id',
+ 'deployment_uuid',
+ 'pull_request_id',
+ 'docker_registry_image_tag',
+ 'force_rebuild',
+ 'commit',
+ 'status',
+ 'is_webhook',
+ 'logs',
+ 'current_process_id',
+ 'restart_only',
+ 'git_type',
+ 'server_id',
+ 'application_name',
+ 'server_name',
+ 'deployment_url',
+ 'destination_id',
+ 'only_this_server',
+ 'rollback',
+ 'commit_message',
+ 'is_api',
+ 'build_server_id',
+ 'horizon_job_id',
+ 'horizon_job_worker',
+ 'finished_at',
+ ];
protected $casts = [
+ 'pull_request_id' => 'integer',
'finished_at' => 'datetime',
];
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index 3b7bf3030..f08a48cea 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -10,7 +10,23 @@ class ApplicationPreview extends BaseModel
{
use SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'application_id',
+ 'pull_request_id',
+ 'pull_request_html_url',
+ 'pull_request_issue_comment_id',
+ 'fqdn',
+ 'status',
+ 'git_type',
+ 'docker_compose_domains',
+ 'docker_registry_image_tag',
+ 'last_online_at',
+ ];
+
+ protected $casts = [
+ 'pull_request_id' => 'integer',
+ ];
protected static function booted()
{
@@ -37,7 +53,7 @@ class ApplicationPreview extends BaseModel
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() > 0) {
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
}
@@ -47,7 +63,7 @@ class ApplicationPreview extends BaseModel
});
static::saving(function ($preview) {
if ($preview->isDirty('status')) {
- $preview->forceFill(['last_online_at' => now()]);
+ $preview->last_online_at = now();
}
});
}
@@ -69,7 +85,7 @@ class ApplicationPreview extends BaseModel
public function persistentStorages()
{
- return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource');
+ return $this->morphMany(LocalPersistentVolume::class, 'resource');
}
public function generate_preview_fqdn()
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index f40977b3e..731a9b5da 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -28,7 +28,43 @@ class ApplicationSetting extends Model
'docker_images_to_keep' => 'integer',
];
- protected $guarded = [];
+ protected $fillable = [
+ 'application_id',
+ 'is_static',
+ 'is_git_submodules_enabled',
+ 'is_git_lfs_enabled',
+ 'is_auto_deploy_enabled',
+ 'is_force_https_enabled',
+ 'is_debug_enabled',
+ 'is_preview_deployments_enabled',
+ 'is_log_drain_enabled',
+ 'is_gpu_enabled',
+ 'gpu_driver',
+ 'gpu_count',
+ 'gpu_device_ids',
+ 'gpu_options',
+ 'is_include_timestamps',
+ 'is_swarm_only_worker_nodes',
+ 'is_raw_compose_deployment_enabled',
+ 'is_build_server_enabled',
+ 'is_consistent_container_name_enabled',
+ 'is_gzip_enabled',
+ 'is_stripprefix_enabled',
+ 'connect_to_docker_network',
+ 'custom_internal_name',
+ 'is_container_label_escape_enabled',
+ 'is_env_sorting_enabled',
+ 'is_container_label_readonly_enabled',
+ 'is_preserve_repository_enabled',
+ 'disable_build_cache',
+ 'is_spa',
+ 'is_git_shallow_clone_enabled',
+ 'is_pr_deployments_public_enabled',
+ 'use_build_secrets',
+ 'inject_build_args_to_dockerfile',
+ 'include_source_commit_in_build',
+ 'docker_images_to_keep',
+ ];
public function isStatic(): Attribute
{
diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php
index 700ab0992..026d11fba 100644
--- a/app/Models/CloudProviderToken.php
+++ b/app/Models/CloudProviderToken.php
@@ -4,7 +4,12 @@ namespace App\Models;
class CloudProviderToken extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'team_id',
+ 'provider',
+ 'token',
+ 'name',
+ ];
protected $casts = [
'token' => 'encrypted',
diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php
index 23e1f0f12..e86598126 100644
--- a/app/Models/DiscordNotificationSettings.php
+++ b/app/Models/DiscordNotificationSettings.php
@@ -24,7 +24,8 @@ class DiscordNotificationSettings extends Model
'backup_failure_discord_notifications',
'scheduled_task_success_discord_notifications',
'scheduled_task_failure_discord_notifications',
- 'docker_cleanup_discord_notifications',
+ 'docker_cleanup_success_discord_notifications',
+ 'docker_cleanup_failure_discord_notifications',
'server_disk_usage_discord_notifications',
'server_reachable_discord_notifications',
'server_unreachable_discord_notifications',
diff --git a/app/Models/DockerCleanupExecution.php b/app/Models/DockerCleanupExecution.php
index 405037e30..280277951 100644
--- a/app/Models/DockerCleanupExecution.php
+++ b/app/Models/DockerCleanupExecution.php
@@ -6,7 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DockerCleanupExecution extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'server_id',
+ 'status',
+ 'message',
+ 'cleanup_log',
+ 'finished_at',
+ ];
public function server(): BelongsTo
{
diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php
index ee31a49b6..1277e45d9 100644
--- a/app/Models/EmailNotificationSettings.php
+++ b/app/Models/EmailNotificationSettings.php
@@ -34,7 +34,11 @@ class EmailNotificationSettings extends Model
'backup_failure_email_notifications',
'scheduled_task_success_email_notifications',
'scheduled_task_failure_email_notifications',
+ 'docker_cleanup_success_email_notifications',
+ 'docker_cleanup_failure_email_notifications',
'server_disk_usage_email_notifications',
+ 'server_reachable_email_notifications',
+ 'server_unreachable_email_notifications',
'server_patch_email_notifications',
'traefik_outdated_email_notifications',
];
diff --git a/app/Models/Environment.php b/app/Models/Environment.php
index d4e614e6e..55830f889 100644
--- a/app/Models/Environment.php
+++ b/app/Models/Environment.php
@@ -25,7 +25,12 @@ class Environment extends BaseModel
use HasFactory;
use HasSafeStringAttribute;
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'description',
+ 'project_id',
+ 'uuid',
+ ];
protected static function booted()
{
@@ -58,7 +63,7 @@ class Environment extends BaseModel
public function environment_variables()
{
- return $this->hasMany(SharedEnvironmentVariable::class);
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'environment');
}
public function applications()
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 5acd4c1e4..83212267c 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -152,6 +152,17 @@ class EnvironmentVariable extends BaseModel
return null;
}
+ // Load relationships needed for shared variable resolution
+ if (! $resource->relationLoaded('environment')) {
+ $resource->load('environment');
+ }
+ if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
+ $resource->load('server');
+ }
+ if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
+ $resource->load('destination.server');
+ }
+
$real_value = $this->get_real_environment_variables($this->value, $resource);
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
@@ -217,9 +228,99 @@ class EnvironmentVariable extends BaseModel
);
}
+ public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
+ {
+ return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
+ }
+
+ public function getResolvedValueWithServer($server = null)
+ {
+ if (! $this->relationLoaded('resourceable')) {
+ $this->load('resourceable');
+ }
+ $resource = $this->resourceable;
+ if (! $resource) {
+ return null;
+ }
+
+ // Load relationships needed for shared variable resolution
+ if (! $resource->relationLoaded('environment')) {
+ $resource->load('environment');
+ }
+ if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
+ $resource->load('server');
+ }
+ if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
+ $resource->load('destination.server');
+ }
+
+ $real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server);
+
+ // Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
+ if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) {
+ return $real_value;
+ }
+
+ if ($this->is_literal || $this->is_multiline) {
+ $real_value = '\''.$real_value.'\'';
+ } else {
+ $real_value = escapeEnvVariables($real_value);
+ }
+
+ return $real_value;
+ }
+
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
{
- return resolveSharedEnvironmentVariables($environment_variable, $resource);
+ return $this->get_real_environment_variables_internal($environment_variable, $resource);
+ }
+
+ private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null)
+ {
+ if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) {
+ return $environment_variable;
+ }
+ $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('/\.(.*)/');
+ $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;
+ } elseif ($type->value() === 'server') {
+ if ($serverOverride) {
+ $id = $serverOverride->id;
+ } elseif (isset($resource->server) && $resource->server) {
+ $id = $resource->server->id;
+ } elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) {
+ $id = $resource->destination->server->id;
+ }
+ }
+ if (is_null($id)) {
+ continue;
+ }
+ $found = SharedEnvironmentVariable::where('type', $type)
+ ->where('key', $variable)
+ ->where('team_id', $resource->team()->id)
+ ->where("{$type}_id", $id)
+ ->first();
+ if ($found) {
+ $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value);
+ }
+ }
+
+ return str($environment_variable)->value();
}
private function get_environment_variables(?string $environment_variable = null): ?string
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index ab82c9a9c..54bbb3f7d 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -6,7 +6,27 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
class GithubApp extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'team_id',
+ 'private_key_id',
+ 'name',
+ 'organization',
+ 'api_url',
+ 'html_url',
+ 'custom_user',
+ 'custom_port',
+ 'app_id',
+ 'installation_id',
+ 'client_id',
+ 'client_secret',
+ 'webhook_secret',
+ 'is_system_wide',
+ 'is_public',
+ 'contents',
+ 'metadata',
+ 'pull_requests',
+ 'administration',
+ ];
protected $appends = ['type'];
@@ -92,7 +112,7 @@ class GithubApp extends BaseModel
{
return Attribute::make(
get: function () {
- if ($this->getMorphClass() === \App\Models\GithubApp::class) {
+ if ($this->getMorphClass() === GithubApp::class) {
return 'github';
}
},
diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php
index 2112a4a66..06df8fd8d 100644
--- a/app/Models/GitlabApp.php
+++ b/app/Models/GitlabApp.php
@@ -4,6 +4,24 @@ namespace App\Models;
class GitlabApp extends BaseModel
{
+ protected $fillable = [
+ 'name',
+ 'organization',
+ 'api_url',
+ 'html_url',
+ 'custom_port',
+ 'custom_user',
+ 'is_system_wide',
+ 'is_public',
+ 'app_id',
+ 'app_secret',
+ 'oauth_id',
+ 'group_name',
+ 'public_key',
+ 'webhook_token',
+ 'deploy_key_id',
+ ];
+
protected $hidden = [
'webhook_token',
'app_secret',
diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php
index ccc361d67..6061bc863 100644
--- a/app/Models/InstanceSettings.php
+++ b/app/Models/InstanceSettings.php
@@ -9,7 +9,43 @@ use Spatie\Url\Url;
class InstanceSettings extends Model
{
- protected $guarded = [];
+ protected $fillable = [
+ 'public_ipv4',
+ 'public_ipv6',
+ 'fqdn',
+ 'public_port_min',
+ 'public_port_max',
+ 'do_not_track',
+ 'is_auto_update_enabled',
+ 'is_registration_enabled',
+ 'next_channel',
+ 'smtp_enabled',
+ 'smtp_from_address',
+ 'smtp_from_name',
+ 'smtp_recipients',
+ 'smtp_host',
+ 'smtp_port',
+ 'smtp_encryption',
+ 'smtp_username',
+ 'smtp_password',
+ 'smtp_timeout',
+ 'resend_enabled',
+ 'resend_api_key',
+ 'is_dns_validation_enabled',
+ 'custom_dns_servers',
+ 'instance_name',
+ 'is_api_enabled',
+ 'allowed_ips',
+ 'auto_update_frequency',
+ 'update_check_frequency',
+ 'new_version_available',
+ 'instance_timezone',
+ 'helper_version',
+ 'disable_two_step_confirmation',
+ 'is_sponsorship_popup_enabled',
+ 'dev_helper_version',
+ 'is_wire_navigate_enabled',
+ ];
protected $casts = [
'smtp_enabled' => 'boolean',
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index b954a1dd5..4b5c602c2 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -20,7 +20,18 @@ class LocalFileVolume extends BaseModel
use HasFactory;
- protected $guarded = [];
+ protected $fillable = [
+ 'fs_path',
+ 'mount_path',
+ 'content',
+ 'resource_type',
+ 'resource_id',
+ 'is_directory',
+ 'chown',
+ 'chmod',
+ 'is_based_on_git',
+ 'is_preview_suffix_enabled',
+ ];
public $appends = ['is_binary'];
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index 9d539f8ec..2f0f482b0 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -7,7 +7,15 @@ use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'mount_path',
+ 'host_path',
+ 'container_id',
+ 'resource_type',
+ 'resource_id',
+ 'is_preview_suffix_enabled',
+ ];
protected $casts = [
'is_preview_suffix_enabled' => 'boolean',
diff --git a/app/Models/Project.php b/app/Models/Project.php
index ed1b415c1..632787a07 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -24,7 +24,12 @@ class Project extends BaseModel
use HasFactory;
use HasSafeStringAttribute;
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'description',
+ 'team_id',
+ 'uuid',
+ ];
/**
* Get query builder for projects owned by current team.
@@ -69,7 +74,7 @@ class Project extends BaseModel
public function environment_variables()
{
- return $this->hasMany(SharedEnvironmentVariable::class);
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'project');
}
public function environments()
diff --git a/app/Models/ProjectSetting.php b/app/Models/ProjectSetting.php
index d93bea05b..8b59ffac6 100644
--- a/app/Models/ProjectSetting.php
+++ b/app/Models/ProjectSetting.php
@@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Model;
class ProjectSetting extends Model
{
- protected $guarded = [];
+ protected $fillable = [
+ 'project_id',
+ ];
public function project()
{
diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php
index 189d05dd4..5ad617ad6 100644
--- a/app/Models/PushoverNotificationSettings.php
+++ b/app/Models/PushoverNotificationSettings.php
@@ -25,7 +25,8 @@ class PushoverNotificationSettings extends Model
'backup_failure_pushover_notifications',
'scheduled_task_success_pushover_notifications',
'scheduled_task_failure_pushover_notifications',
- 'docker_cleanup_pushover_notifications',
+ 'docker_cleanup_success_pushover_notifications',
+ 'docker_cleanup_failure_pushover_notifications',
'server_disk_usage_pushover_notifications',
'server_reachable_pushover_notifications',
'server_unreachable_pushover_notifications',
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index f395a065c..d6feccc7e 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -12,7 +12,17 @@ class S3Storage extends BaseModel
{
use HasFactory, HasSafeStringAttribute;
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'description',
+ 'region',
+ 'key',
+ 'secret',
+ 'bucket',
+ 'endpoint',
+ 'is_usable',
+ 'unusable_email_sent',
+ ];
protected $casts = [
'is_usable' => 'boolean',
diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php
index 3ade21df8..6308bae8b 100644
--- a/app/Models/ScheduledDatabaseBackup.php
+++ b/app/Models/ScheduledDatabaseBackup.php
@@ -8,7 +8,27 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledDatabaseBackup extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'team_id',
+ 'description',
+ 'enabled',
+ 'save_s3',
+ 'frequency',
+ 'database_backup_retention_amount_locally',
+ 'database_type',
+ 'database_id',
+ 's3_storage_id',
+ 'databases_to_backup',
+ 'dump_all',
+ 'database_backup_retention_days_locally',
+ 'database_backup_retention_max_storage_locally',
+ 'database_backup_retention_amount_s3',
+ 'database_backup_retention_days_s3',
+ 'database_backup_retention_max_storage_s3',
+ 'timeout',
+ 'disable_local_backup',
+ ];
public static function ownedByCurrentTeam()
{
diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php
index c0298ecc8..51ad46de9 100644
--- a/app/Models/ScheduledDatabaseBackupExecution.php
+++ b/app/Models/ScheduledDatabaseBackupExecution.php
@@ -6,7 +6,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ScheduledDatabaseBackupExecution extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'scheduled_database_backup_id',
+ 'status',
+ 'message',
+ 'size',
+ 'filename',
+ 'database_name',
+ 'finished_at',
+ 'local_storage_deleted',
+ 's3_storage_deleted',
+ 's3_uploaded',
+ ];
protected function casts(): array
{
diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php
index e771ce31e..40f8e1860 100644
--- a/app/Models/ScheduledTask.php
+++ b/app/Models/ScheduledTask.php
@@ -29,7 +29,18 @@ class ScheduledTask extends BaseModel
use HasFactory;
use HasSafeStringAttribute;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'enabled',
+ 'name',
+ 'command',
+ 'frequency',
+ 'container',
+ 'timeout',
+ 'team_id',
+ 'application_id',
+ 'service_id',
+ ];
public static function ownedByCurrentTeamAPI(int $teamId)
{
diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php
index c0601a4c9..1e26c7be3 100644
--- a/app/Models/ScheduledTaskExecution.php
+++ b/app/Models/ScheduledTaskExecution.php
@@ -22,7 +22,16 @@ use OpenApi\Attributes as OA;
)]
class ScheduledTaskExecution extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'scheduled_task_id',
+ 'status',
+ 'message',
+ 'finished_at',
+ 'started_at',
+ 'retry_count',
+ 'duration',
+ 'error_details',
+ ];
protected function casts(): array
{
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 9237763c8..06426f211 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -34,6 +34,7 @@ use OpenApi\Attributes as OA;
use Spatie\SchemalessAttributes\Casts\SchemalessAttributes;
use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
use Spatie\Url\Url;
+use Stevebauman\Purify\Facades\Purify;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
@@ -134,7 +135,7 @@ class Server extends BaseModel
$payload['ip_previous'] = $server->getOriginal('ip');
}
}
- $server->forceFill($payload);
+ $server->fill($payload);
});
static::saved(function ($server) {
if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) {
@@ -147,19 +148,14 @@ class Server extends BaseModel
]);
if ($server->id === 0) {
if ($server->isSwarm()) {
- SwarmDocker::create([
+ (new SwarmDocker)->forceFill([
'id' => 0,
'name' => 'coolify',
'network' => 'coolify-overlay',
'server_id' => $server->id,
- ]);
+ ])->save();
} else {
- StandaloneDocker::create([
- 'id' => 0,
- 'name' => 'coolify',
- 'network' => 'coolify',
- 'server_id' => $server->id,
- ]);
+ (new StandaloneDocker)->forceFill($server->defaultStandaloneDockerAttributes(id: 0))->saveQuietly();
}
} else {
if ($server->isSwarm()) {
@@ -169,18 +165,32 @@ class Server extends BaseModel
'server_id' => $server->id,
]);
} else {
- $standaloneDocker = new StandaloneDocker([
- 'name' => 'coolify',
- 'uuid' => (string) new Cuid2,
- 'network' => 'coolify',
- 'server_id' => $server->id,
- ]);
+ $standaloneDocker = new StandaloneDocker;
+ $standaloneDocker->forceFill($server->defaultStandaloneDockerAttributes());
$standaloneDocker->saveQuietly();
}
}
if (! isset($server->proxy->redirect_enabled)) {
$server->proxy->redirect_enabled = true;
}
+
+ // Create predefined server shared variables
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'value' => $server->uuid,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'is_literal' => true,
+ ]);
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'value' => $server->name,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'is_literal' => true,
+ ]);
});
static::retrieved(function ($server) {
if (! isset($server->proxy->redirect_enabled)) {
@@ -263,12 +273,18 @@ class Server extends BaseModel
'detected_traefik_version',
'traefik_outdated_info',
'server_metadata',
+ 'ip_previous',
];
- protected $guarded = [];
-
use HasSafeStringAttribute;
+ public function setValidationLogsAttribute($value): void
+ {
+ $this->attributes['validation_logs'] = $value !== null
+ ? Purify::config('validation_logs')->clean($value)
+ : null;
+ }
+
public function type()
{
return 'server';
@@ -1017,6 +1033,30 @@ $schema://$host {
return $this->belongsTo(Team::class);
}
+ /**
+ * @return array{id?: int, name: string, uuid: string, network: string, server_id: int}
+ */
+ public function defaultStandaloneDockerAttributes(?int $id = null): array
+ {
+ $attributes = [
+ 'name' => 'coolify',
+ 'uuid' => (string) new Cuid2,
+ 'network' => 'coolify',
+ 'server_id' => $this->id,
+ ];
+
+ if (! is_null($id)) {
+ $attributes['id'] = $id;
+ }
+
+ return $attributes;
+ }
+
+ public function environment_variables()
+ {
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server');
+ }
+
public function isProxyShouldRun()
{
// TODO: Do we need "|| $this->proxy->force_stop" here?
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 504cfa60a..30fc1e165 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -53,9 +53,54 @@ use OpenApi\Attributes as OA;
)]
class ServerSetting extends Model
{
- protected $guarded = [];
+ protected $fillable = [
+ 'server_id',
+ 'is_swarm_manager',
+ 'is_jump_server',
+ 'is_build_server',
+ 'is_reachable',
+ 'is_usable',
+ 'wildcard_domain',
+ 'is_cloudflare_tunnel',
+ 'is_logdrain_newrelic_enabled',
+ 'logdrain_newrelic_license_key',
+ 'logdrain_newrelic_base_uri',
+ 'is_logdrain_highlight_enabled',
+ 'logdrain_highlight_project_id',
+ 'is_logdrain_axiom_enabled',
+ 'logdrain_axiom_dataset_name',
+ 'logdrain_axiom_api_key',
+ 'is_swarm_worker',
+ 'is_logdrain_custom_enabled',
+ 'logdrain_custom_config',
+ 'logdrain_custom_config_parser',
+ 'concurrent_builds',
+ 'dynamic_timeout',
+ 'force_disabled',
+ 'is_metrics_enabled',
+ 'generate_exact_labels',
+ 'force_docker_cleanup',
+ 'docker_cleanup_frequency',
+ 'docker_cleanup_threshold',
+ 'server_timezone',
+ 'delete_unused_volumes',
+ 'delete_unused_networks',
+ 'is_sentinel_enabled',
+ 'sentinel_token',
+ 'sentinel_metrics_refresh_rate_seconds',
+ 'sentinel_metrics_history_days',
+ 'sentinel_push_interval_seconds',
+ 'sentinel_custom_url',
+ 'server_disk_usage_notification_threshold',
+ 'is_sentinel_debug_enabled',
+ 'server_disk_usage_check_frequency',
+ 'is_terminal_enabled',
+ 'deployment_queue_limit',
+ 'disable_application_image_retention',
+ ];
protected $casts = [
+ 'force_disabled' => 'boolean',
'force_docker_cleanup' => 'boolean',
'docker_cleanup_threshold' => 'integer',
'sentinel_token' => 'encrypted',
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 84c047bb7..11189b4ac 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
+use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
#[OA\Schema(
@@ -47,7 +48,22 @@ class Service extends BaseModel
private static $parserVersion = '5';
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'docker_compose_raw',
+ 'docker_compose',
+ 'connect_to_docker_network',
+ 'service_type',
+ 'config_hash',
+ 'compose_parsing_version',
+ 'is_container_label_escape_enabled',
+ 'environment_id',
+ 'server_id',
+ 'destination_id',
+ 'destination_type',
+ ];
protected $appends = ['server_status', 'status'];
@@ -1552,7 +1568,7 @@ class Service extends BaseModel
// Generate SERVICE_NAME_* environment variables from docker-compose services
if ($this->docker_compose) {
try {
- $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose);
+ $dockerCompose = Yaml::parse($this->docker_compose);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php
index 4bf78085e..6bf12f4e7 100644
--- a/app/Models/ServiceApplication.php
+++ b/app/Models/ServiceApplication.php
@@ -5,12 +5,31 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Symfony\Component\Yaml\Yaml;
class ServiceApplication extends BaseModel
{
use HasFactory, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'service_id',
+ 'name',
+ 'human_name',
+ 'description',
+ 'fqdn',
+ 'ports',
+ 'exposes',
+ 'status',
+ 'exclude_from_status',
+ 'required_fqdn',
+ 'image',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'is_gzip_enabled',
+ 'is_stripprefix_enabled',
+ 'last_online_at',
+ 'is_migrated',
+ ];
protected static function booted()
{
@@ -21,7 +40,7 @@ class ServiceApplication extends BaseModel
});
static::saving(function ($service) {
if ($service->isDirty('status')) {
- $service->forceFill(['last_online_at' => now()]);
+ $service->last_online_at = now();
}
});
}
@@ -211,7 +230,7 @@ class ServiceApplication extends BaseModel
return $this->service->getRequiredPort();
}
- $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $dockerCompose = Yaml::parse($dockerComposeRaw);
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
if (! $serviceConfig) {
return $this->service->getRequiredPort();
diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php
index c6a0143a8..69801f985 100644
--- a/app/Models/ServiceDatabase.php
+++ b/app/Models/ServiceDatabase.php
@@ -9,7 +9,28 @@ class ServiceDatabase extends BaseModel
{
use HasFactory, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'service_id',
+ 'name',
+ 'human_name',
+ 'description',
+ 'fqdn',
+ 'ports',
+ 'exposes',
+ 'status',
+ 'exclude_from_status',
+ 'image',
+ 'public_port',
+ 'is_public',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'is_gzip_enabled',
+ 'is_stripprefix_enabled',
+ 'last_online_at',
+ 'is_migrated',
+ 'custom_type',
+ 'public_port_timeout',
+ ];
protected $casts = [
'public_port_timeout' => 'integer',
@@ -24,7 +45,7 @@ class ServiceDatabase extends BaseModel
});
static::saving(function ($service) {
if ($service->isDirty('status')) {
- $service->forceFill(['last_online_at' => now()]);
+ $service->last_online_at = now();
}
});
}
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index 9bd42c328..fa6fd45e0 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -17,11 +17,15 @@ class SharedEnvironmentVariable extends Model
'team_id',
'project_id',
'environment_id',
+ 'server_id',
// Boolean flags
'is_multiline',
'is_literal',
'is_shown_once',
+
+ // Metadata
+ 'version',
];
protected $casts = [
@@ -43,4 +47,9 @@ class SharedEnvironmentVariable extends Model
{
return $this->belongsTo(Environment::class);
}
+
+ public function server()
+ {
+ return $this->belongsTo(Server::class);
+ }
}
diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php
index 128b25221..d4f125fb5 100644
--- a/app/Models/SlackNotificationSettings.php
+++ b/app/Models/SlackNotificationSettings.php
@@ -24,7 +24,8 @@ class SlackNotificationSettings extends Model
'backup_failure_slack_notifications',
'scheduled_task_success_slack_notifications',
'scheduled_task_failure_slack_notifications',
- 'docker_cleanup_slack_notifications',
+ 'docker_cleanup_success_slack_notifications',
+ 'docker_cleanup_failure_slack_notifications',
'server_disk_usage_slack_notifications',
'server_reachable_slack_notifications',
'server_unreachable_slack_notifications',
diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 33f32dd59..784e2c937 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -13,12 +13,43 @@ class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'clickhouse_admin_user',
+ 'clickhouse_admin_password',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'custom_docker_run_options',
+ 'clickhouse_db',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
- 'clickhouse_password' => 'encrypted',
+ 'clickhouse_admin_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
@@ -44,7 +75,7 @@ class StandaloneClickhouse extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -135,7 +166,7 @@ class StandaloneClickhouse extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php
index 0407c2255..dcb349405 100644
--- a/app/Models/StandaloneDocker.php
+++ b/app/Models/StandaloneDocker.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Jobs\ConnectProxyToNetworksJob;
+use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,20 +12,34 @@ class StandaloneDocker extends BaseModel
use HasFactory;
use HasSafeStringAttribute;
- protected $guarded = [];
+ protected $fillable = [
+ 'server_id',
+ 'name',
+ 'network',
+ ];
protected static function boot()
{
parent::boot();
static::created(function ($newStandaloneDocker) {
$server = $newStandaloneDocker->server;
+ $safeNetwork = escapeshellarg($newStandaloneDocker->network);
instant_remote_process([
- "docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null",
+ "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null",
], $server, false);
ConnectProxyToNetworksJob::dispatchSync($server);
});
}
+ public function setNetworkAttribute(string $value): void
+ {
+ if (! ValidationPatterns::isValidDockerNetwork($value)) {
+ throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.');
+ }
+
+ $this->attributes['network'] = $value;
+ }
+
public function applications()
{
return $this->morphMany(Application::class, 'destination');
diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index 074c5b509..e07053c03 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -13,7 +13,37 @@ class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'dragonfly_password',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
@@ -44,7 +74,7 @@ class StandaloneDragonfly extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -135,7 +165,7 @@ class StandaloneDragonfly extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index 23b4c65e6..979f45a3d 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -13,7 +13,38 @@ class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'keydb_password',
+ 'keydb_conf',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
@@ -44,7 +75,7 @@ class StandaloneKeydb extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -135,7 +166,7 @@ class StandaloneKeydb extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index 4d4b84776..dba8a52f5 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -14,7 +14,40 @@ class StandaloneMariadb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'mariadb_root_password',
+ 'mariadb_user',
+ 'mariadb_password',
+ 'mariadb_database',
+ 'mariadb_conf',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'is_log_drain_enabled',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
@@ -45,7 +78,7 @@ class StandaloneMariadb extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -136,7 +169,7 @@ class StandaloneMariadb extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index b5401dd2c..e72f4f1c6 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -13,7 +13,41 @@ class StandaloneMongodb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'mongo_conf',
+ 'mongo_initdb_root_username',
+ 'mongo_initdb_root_password',
+ 'mongo_initdb_database',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'ssl_mode',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
@@ -50,7 +84,7 @@ class StandaloneMongodb extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -141,7 +175,7 @@ class StandaloneMongodb extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index 0b144575c..1c522d200 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -13,7 +13,42 @@ class StandaloneMysql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'mysql_root_password',
+ 'mysql_user',
+ 'mysql_password',
+ 'mysql_database',
+ 'mysql_conf',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'ssl_mode',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
@@ -45,7 +80,7 @@ class StandaloneMysql extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -136,7 +171,7 @@ class StandaloneMysql extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index 92b2efd31..57dfe5988 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -13,7 +13,44 @@ class StandalonePostgresql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'postgres_user',
+ 'postgres_password',
+ 'postgres_db',
+ 'postgres_initdb_args',
+ 'postgres_host_auth_method',
+ 'postgres_conf',
+ 'init_scripts',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'ssl_mode',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
@@ -59,7 +96,7 @@ class StandalonePostgresql extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
}
@@ -114,7 +151,7 @@ class StandalonePostgresql extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index 352d27cfd..ef42d7f18 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -13,7 +13,37 @@ class StandaloneRedis extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'redis_conf',
+ 'status',
+ 'image',
+ 'is_public',
+ 'public_port',
+ 'ports_mappings',
+ 'limits_memory',
+ 'limits_memory_swap',
+ 'limits_memory_swappiness',
+ 'limits_memory_reservation',
+ 'limits_cpus',
+ 'limits_cpuset',
+ 'limits_cpu_shares',
+ 'started_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ 'last_online_at',
+ 'public_port_timeout',
+ 'enable_ssl',
+ 'is_log_drain_enabled',
+ 'is_include_timestamps',
+ 'custom_docker_run_options',
+ 'destination_type',
+ 'destination_id',
+ 'environment_id',
+ ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
@@ -43,7 +73,7 @@ class StandaloneRedis extends BaseModel
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
- $database->forceFill(['last_online_at' => now()]);
+ $database->last_online_at = now();
}
});
@@ -140,7 +170,7 @@ class StandaloneRedis extends BaseModel
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
- instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index 69d7cbf0d..b0fec64f9 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -6,7 +6,19 @@ use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
- protected $guarded = [];
+ protected $fillable = [
+ 'team_id',
+ 'stripe_invoice_paid',
+ 'stripe_subscription_id',
+ 'stripe_customer_id',
+ 'stripe_cancel_at_period_end',
+ 'stripe_plan_id',
+ 'stripe_feedback',
+ 'stripe_comment',
+ 'stripe_trial_already_ended',
+ 'stripe_past_due',
+ 'stripe_refunded_at',
+ ];
protected function casts(): array
{
diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php
index 08be81970..134e36189 100644
--- a/app/Models/SwarmDocker.php
+++ b/app/Models/SwarmDocker.php
@@ -2,9 +2,24 @@
namespace App\Models;
+use App\Support\ValidationPatterns;
+
class SwarmDocker extends BaseModel
{
- protected $guarded = [];
+ protected $fillable = [
+ 'server_id',
+ 'name',
+ 'network',
+ ];
+
+ public function setNetworkAttribute(string $value): void
+ {
+ if (! ValidationPatterns::isValidDockerNetwork($value)) {
+ throw new \InvalidArgumentException('Invalid Docker network name. Must start with alphanumeric and contain only alphanumeric characters, dots, hyphens, and underscores.');
+ }
+
+ $this->attributes['network'] = $value;
+ }
public function applications()
{
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index 3594d1072..e6fbd3a06 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -8,7 +8,10 @@ class Tag extends BaseModel
{
use HasSafeStringAttribute;
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'team_id',
+ ];
protected function customizeName($value)
{
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 5a7b377b6..8a54a9dee 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -40,7 +40,13 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
{
use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'description',
+ 'personal_team',
+ 'show_boarding',
+ 'custom_server_limit',
+ ];
protected $casts = [
'personal_team' => 'boolean',
@@ -226,7 +232,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
public function environment_variables()
{
- return $this->hasMany(SharedEnvironmentVariable::class)->whereNull('project_id')->whereNull('environment_id');
+ return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'team');
}
public function members()
diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php
index 73889910e..4930f45d4 100644
--- a/app/Models/TelegramNotificationSettings.php
+++ b/app/Models/TelegramNotificationSettings.php
@@ -25,7 +25,8 @@ class TelegramNotificationSettings extends Model
'backup_failure_telegram_notifications',
'scheduled_task_success_telegram_notifications',
'scheduled_task_failure_telegram_notifications',
- 'docker_cleanup_telegram_notifications',
+ 'docker_cleanup_success_telegram_notifications',
+ 'docker_cleanup_failure_telegram_notifications',
'server_disk_usage_telegram_notifications',
'server_reachable_telegram_notifications',
'server_unreachable_telegram_notifications',
@@ -39,7 +40,8 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_backup_failure_thread_id',
'telegram_notifications_scheduled_task_success_thread_id',
'telegram_notifications_scheduled_task_failure_thread_id',
- 'telegram_notifications_docker_cleanup_thread_id',
+ 'telegram_notifications_docker_cleanup_success_thread_id',
+ 'telegram_notifications_docker_cleanup_failure_thread_id',
'telegram_notifications_server_disk_usage_thread_id',
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
diff --git a/app/Models/User.php b/app/Models/User.php
index 4561cddb2..3199d2024 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -4,7 +4,9 @@ namespace App\Models;
use App\Jobs\UpdateStripeCustomerEmailJob;
use App\Notifications\Channels\SendsEmail;
+use App\Notifications\TransactionalEmails\EmailChangeVerification;
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
+use App\Services\ChangelogService;
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -41,7 +43,16 @@ class User extends Authenticatable implements SendsEmail
{
use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
- protected $guarded = [];
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ 'force_password_reset',
+ 'marketing_emails',
+ 'pending_email',
+ 'email_change_code',
+ 'email_change_code_expires_at',
+ ];
protected $hidden = [
'password',
@@ -87,7 +98,8 @@ class User extends Authenticatable implements SendsEmail
$team['id'] = 0;
$team['name'] = 'Root Team';
}
- $new_team = Team::create($team);
+ $new_team = (new Team)->forceFill($team);
+ $new_team->save();
$user->teams()->attach($new_team, ['role' => 'owner']);
});
@@ -190,7 +202,8 @@ class User extends Authenticatable implements SendsEmail
$team['id'] = 0;
$team['name'] = 'Root Team';
}
- $new_team = Team::create($team);
+ $new_team = (new Team)->forceFill($team);
+ $new_team->save();
$this->teams()->attach($new_team, ['role' => 'owner']);
return $new_team;
@@ -228,7 +241,7 @@ class User extends Authenticatable implements SendsEmail
public function getUnreadChangelogCount(): int
{
- return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this);
+ return app(ChangelogService::class)->getUnreadCountForUser($this);
}
public function getRecipients(): array
@@ -239,7 +252,7 @@ class User extends Authenticatable implements SendsEmail
public function sendVerificationEmail()
{
$mail = new MailMessage;
- $url = Url::temporarySignedRoute(
+ $url = URL::temporarySignedRoute(
'verify.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
@@ -395,20 +408,20 @@ class User extends Authenticatable implements SendsEmail
public function requestEmailChange(string $newEmail): void
{
// Generate 6-digit code
- $code = sprintf('%06d', mt_rand(0, 999999));
+ $code = sprintf('%06d', random_int(0, 999999));
// Set expiration using config value
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
$expiresAt = Carbon::now()->addMinutes($expiryMinutes);
- $this->update([
+ $this->fill([
'pending_email' => $newEmail,
'email_change_code' => $code,
'email_change_code_expires_at' => $expiresAt,
- ]);
+ ])->save();
// Send verification email to new address
- $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt));
+ $this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt));
}
public function isEmailChangeCodeValid(string $code): bool
diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php
index 7a1f7bcbf..4b56f5860 100644
--- a/app/Notifications/Server/ForceDisabled.php
+++ b/app/Notifications/Server/ForceDisabled.php
@@ -40,7 +40,7 @@ class ForceDisabled extends CustomEmailNotification
color: DiscordMessage::errorColor(),
);
- $message->addField('Please update your subscription to enable the server again!', '[Link](https://app.coolify.io/subscriptions)');
+ $message->addField('Please update your subscription to enable the server again!', '[Link](https://app.coolify.io/subscription)');
return $message;
}
@@ -48,7 +48,7 @@ class ForceDisabled extends CustomEmailNotification
public function toTelegram(): array
{
return [
- 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).",
+ 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscription).",
];
}
@@ -57,7 +57,7 @@ class ForceDisabled extends CustomEmailNotification
return new PushoverMessage(
title: 'Server disabled',
level: 'error',
- message: "Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped. Please update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).",
+ message: "Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped. Please update your subscription to enable the server again [here](https://app.coolify.io/subscription).",
);
}
@@ -66,7 +66,7 @@ class ForceDisabled extends CustomEmailNotification
$title = 'Server disabled';
$description = "Server ({$this->server->name}) disabled because it is not paid!\n";
$description .= "All automations and integrations are stopped.\n\n";
- $description .= 'Please update your subscription to enable the server again: https://app.coolify.io/subscriptions';
+ $description .= 'Please update your subscription to enable the server again: https://app.coolify.io/subscription';
return new SlackMessage(
title: $title,
diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php
index 179c8d948..511818e21 100644
--- a/app/Notifications/TransactionalEmails/ResetPassword.php
+++ b/app/Notifications/TransactionalEmails/ResetPassword.php
@@ -67,9 +67,12 @@ class ResetPassword extends Notification
return call_user_func(static::$createUrlCallback, $notifiable, $this->token);
}
- return url(route('password.reset', [
+ $path = route('password.reset', [
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
- ], false));
+ ], false);
+
+ // Use server-side config (FQDN / public IP) instead of request host
+ return rtrim(base_url(), '/').$path;
}
}
diff --git a/app/Rules/SafeExternalUrl.php b/app/Rules/SafeExternalUrl.php
new file mode 100644
index 000000000..41299d6c1
--- /dev/null
+++ b/app/Rules/SafeExternalUrl.php
@@ -0,0 +1,81 @@
+ $attribute,
+ 'url' => $value,
+ 'host' => $host,
+ 'ip' => request()->ip(),
+ 'user_id' => auth()->id(),
+ ]);
+ $fail('The :attribute must not point to internal hosts.');
+
+ return;
+ }
+
+ // Resolve hostname to IP and block private/reserved ranges
+ $ip = gethostbyname($host);
+
+ // gethostbyname returns the original hostname on failure (e.g. unresolvable)
+ if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) {
+ $fail('The :attribute host could not be resolved.');
+
+ return;
+ }
+
+ if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+ Log::warning('External URL resolves to private or reserved IP', [
+ 'attribute' => $attribute,
+ 'url' => $value,
+ 'host' => $host,
+ 'resolved_ip' => $ip,
+ 'ip' => request()->ip(),
+ 'user_id' => auth()->id(),
+ ]);
+ $fail('The :attribute must not point to a private or reserved IP address.');
+
+ return;
+ }
+ }
+}
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
new file mode 100644
index 000000000..fbeb406af
--- /dev/null
+++ b/app/Rules/SafeWebhookUrl.php
@@ -0,0 +1,95 @@
+ $attribute,
+ 'host' => $host,
+ 'ip' => request()->ip(),
+ 'user_id' => auth()->id(),
+ ]);
+ $fail('The :attribute must not point to localhost or internal hosts.');
+
+ return;
+ }
+
+ // Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
+ if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) {
+ Log::warning('Webhook URL points to blocked IP range', [
+ 'attribute' => $attribute,
+ 'host' => $host,
+ 'ip' => request()->ip(),
+ 'user_id' => auth()->id(),
+ ]);
+ $fail('The :attribute must not point to loopback or link-local addresses.');
+
+ return;
+ }
+ }
+
+ private function isLoopback(string $ip): bool
+ {
+ // 127.0.0.0/8, 0.0.0.0
+ if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
+ return true;
+ }
+
+ // IPv6 loopback
+ $normalized = @inet_pton($ip);
+
+ return $normalized !== false && $normalized === inet_pton('::1');
+ }
+
+ private function isLinkLocal(string $ip): bool
+ {
+ // 169.254.0.0/16 โ covers cloud metadata at 169.254.169.254
+ if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ return false;
+ }
+
+ $long = ip2long($ip);
+
+ return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
+ }
+}
diff --git a/app/Rules/ValidDnsServers.php b/app/Rules/ValidDnsServers.php
new file mode 100644
index 000000000..e3bbd048f
--- /dev/null
+++ b/app/Rules/ValidDnsServers.php
@@ -0,0 +1,35 @@
+ 'The volume name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.',
+ ];
+ }
+
+ /**
+ * Pattern for port mappings (e.g. 3000:3000, 8080:80, 8000-8010:8000-8010)
+ * Each entry requires host:container format, where each side can be a number or a range (number-number)
+ */
+ public const PORT_MAPPINGS_PATTERN = '/^(\d+(-\d+)?:\d+(-\d+)?)(,\d+(-\d+)?:\d+(-\d+)?)*$/';
+
/**
* Get validation rules for container name fields
*/
@@ -165,6 +215,24 @@ class ValidationPatterns
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
+ /**
+ * Get validation rules for port mapping fields
+ */
+ public static function portMappingRules(): array
+ {
+ return ['nullable', 'string', 'regex:'.self::PORT_MAPPINGS_PATTERN];
+ }
+
+ /**
+ * Get validation messages for port mapping fields
+ */
+ public static function portMappingMessages(string $field = 'portsMappings'): array
+ {
+ return [
+ "{$field}.regex" => 'Port mappings must be a comma-separated list of port pairs or ranges (e.g. 3000:3000,8080:80,8000-8010:8000-8010).',
+ ];
+ }
+
/**
* Check if a string is a valid Docker container name.
*/
@@ -173,6 +241,44 @@ class ValidationPatterns
return preg_match(self::CONTAINER_NAME_PATTERN, $name) === 1;
}
+ /**
+ * Get validation rules for Docker network name fields
+ */
+ public static function dockerNetworkRules(bool $required = true, int $maxLength = 255): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "max:$maxLength";
+ $rules[] = 'regex:'.self::DOCKER_NETWORK_PATTERN;
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for Docker network name fields
+ */
+ public static function dockerNetworkMessages(string $field = 'network'): array
+ {
+ return [
+ "{$field}.regex" => 'The network name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.',
+ ];
+ }
+
+ /**
+ * Check if a string is a valid Docker network name.
+ */
+ public static function isValidDockerNetwork(string $name): bool
+ {
+ return preg_match(self::DOCKER_NETWORK_PATTERN, $name) === 1;
+ }
+
/**
* Get combined validation messages for both name and description fields
*/
diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php
index 4a98e4a51..faef64a36 100644
--- a/app/View/Components/Forms/EnvVarInput.php
+++ b/app/View/Components/Forms/EnvVarInput.php
@@ -38,6 +38,7 @@ class EnvVarInput extends Component
public array $availableVars = [],
public ?string $projectUuid = null,
public ?string $environmentUuid = null,
+ public ?string $serverUuid = null,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
@@ -86,6 +87,9 @@ class EnvVarInput extends Component
'environment_uuid' => $this->environmentUuid,
])
: route('shared-variables.environment.index'),
+ 'server' => $this->serverUuid
+ ? route('shared-variables.server.show', ['server_uuid' => $this->serverUuid])
+ : route('shared-variables.server.index'),
'default' => route('shared-variables.index'),
];
diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php
index a5303b947..02a23a26a 100644
--- a/app/View/Components/Forms/Textarea.php
+++ b/app/View/Components/Forms/Textarea.php
@@ -32,10 +32,11 @@ class Textarea extends Component
public bool $allowTab = false,
public bool $spellcheck = false,
public bool $autofocus = false,
+ public bool $monospace = false,
public ?string $helper = null,
public bool $realtimeValidation = false,
public bool $allowToPeak = true,
- public string $defaultClass = 'input scrollbar font-mono',
+ public string $defaultClass = 'input scrollbar',
public string $defaultClassInput = 'input',
public ?int $minlength = null,
public ?int $maxlength = null,
@@ -81,6 +82,10 @@ class Textarea extends Component
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
+ if ($this->monospace) {
+ $this->defaultClass .= ' font-mono';
+ }
+
// $this->label = Str::title($this->label);
return view('components.forms.textarea');
}
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index ec42761f7..c10ed6158 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -95,9 +95,9 @@ function sharedDataApplications()
'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',
- 'build_command' => 'string|nullable',
- 'start_command' => 'string|nullable',
+ 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
+ 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
+ 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
@@ -144,6 +144,7 @@ function sharedDataApplications()
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
+ 'is_preserve_repository_enabled' => 'boolean'
];
}
@@ -193,5 +194,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
+ $request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('docker_compose_raw');
}
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index c522cd0ca..48e0a8c78 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -6,12 +6,13 @@ use App\Jobs\ApplicationDeploymentJob;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
+use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
-function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false)
+function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
@@ -46,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)
->where('pull_request_id', $pull_request_id)
+ ->where('docker_registry_image_tag', $docker_registry_image_tag)
->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])
->first();
@@ -71,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme
'deployment_uuid' => $deployment_uuid,
'deployment_url' => $deployment_url,
'pull_request_id' => $pull_request_id,
+ 'docker_registry_image_tag' => $docker_registry_image_tag,
'force_rebuild' => $force_rebuild,
'is_webhook' => $is_webhook,
'is_api' => $is_api,
@@ -192,7 +195,7 @@ function clone_application(Application $source, $destination, array $overrides =
$server = $destination->server;
if ($server->team_id !== currentTeam()->id) {
- throw new \RuntimeException('Destination does not belong to the current team.');
+ throw new RuntimeException('Destination does not belong to the current team.');
}
// Prepare name and URL
@@ -238,6 +241,7 @@ function clone_application(Application $source, $destination, array $overrides =
'application_id' => $newApplication->id,
]);
$newApplicationSettings->save();
+ $newApplication->setRelation('settings', $newApplicationSettings->fresh());
}
// Clone tags
@@ -299,6 +303,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $newApplication->id,
@@ -322,8 +327,8 @@ function clone_application(Application $source, $destination, array $overrides =
destination: $source->destination,
no_questions_asked: true
);
- } catch (\Exception $e) {
- \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
+ } catch (Exception $e) {
+ Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
@@ -344,7 +349,7 @@ function clone_application(Application $source, $destination, array $overrides =
// Clone production environment variables without triggering the created hook
$environmentVariables = $source->environment_variables()->get();
foreach ($environmentVariables as $environmentVariable) {
- \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
+ EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
$newEnvironmentVariable = $environmentVariable->replicate([
'id',
'created_at',
@@ -361,7 +366,7 @@ function clone_application(Application $source, $destination, array $overrides =
// Clone preview environment variables
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
- \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
+ EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
'id',
'created_at',
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index 30ca36f2e..bae2573de 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -81,4 +81,4 @@ const NEEDS_TO_DISABLE_GZIP = [
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
-const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];
+const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment', 'server'];
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 4ca693fcb..123cf906a 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -22,25 +22,25 @@ use Visus\Cuid2\Cuid2;
*
* @param string $composeYaml The raw Docker Compose YAML content
*
- * @throws \Exception If the compose file contains command injection attempts
+ * @throws Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
- } catch (\Exception $e) {
- throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
+ } catch (Exception $e) {
+ throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
- throw new \Exception('Docker Compose file must contain a "services" section');
+ throw new Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
@@ -68,8 +68,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($source, 'volume source');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
@@ -84,8 +84,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
@@ -105,7 +105,7 @@ function validateDockerComposeForInjection(string $composeYaml): void
*
* @param string $volumeString The volume string to validate
*
- * @throws \Exception If the volume string contains command injection attempts
+ * @throws Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
@@ -325,9 +325,9 @@ function parseDockerVolumeString(string $volumeString): array
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceStr, 'volume source');
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// Re-throw with more context about the volume string
- throw new \Exception(
+ throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -343,8 +343,8 @@ function parseDockerVolumeString(string $volumeString): array
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -375,7 +375,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
try {
$yaml = Yaml::parse($compose);
- } catch (\Exception) {
+ } catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
@@ -409,8 +409,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
@@ -465,7 +465,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
}
- if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
+ if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
@@ -738,8 +738,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -749,8 +749,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -1489,7 +1489,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
@@ -1519,7 +1519,7 @@ function serviceParser(Service $resource): Collection
try {
$yaml = Yaml::parse($compose);
- } catch (\Exception) {
+ } catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
@@ -1566,8 +1566,8 @@ function serviceParser(Service $resource): Collection
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
@@ -1593,20 +1593,25 @@ function serviceParser(Service $resource): Collection
// Use image detection for non-migrated services
$isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) {
- $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
- if ($applicationFound) {
- $savedService = $applicationFound;
+ $databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first();
+ if ($databaseFound) {
+ $savedService = $databaseFound;
} else {
- $savedService = ServiceDatabase::firstOrCreate([
+ $savedService = ServiceDatabase::create([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
} else {
- $savedService = ServiceApplication::firstOrCreate([
- 'name' => $serviceName,
- 'service_id' => $resource->id,
- ]);
+ $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
+ if ($applicationFound) {
+ $savedService = $applicationFound;
+ } else {
+ $savedService = ServiceApplication::create([
+ 'name' => $serviceName,
+ 'service_id' => $resource->id,
+ ]);
+ }
}
}
// Update image if it changed
@@ -1772,7 +1777,7 @@ function serviceParser(Service $resource): Collection
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
- if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
+ if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
@@ -2120,8 +2125,8 @@ function serviceParser(Service $resource): Collection
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -2131,8 +2136,8 @@ function serviceParser(Service $resource): Collection
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
- } catch (\Exception $e) {
- throw new \Exception(
+ } catch (Exception $e) {
+ throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -2741,7 +2746,7 @@ function serviceParser(Service $resource): Collection
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index cf9f648bb..ed18dfe76 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -109,18 +109,20 @@ function connectProxyToNetworks(Server $server)
['networks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
+ $safe = escapeshellarg($network);
return [
- "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null",
- "docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
- "echo 'Successfully connected coolify-proxy to $network network.'",
+ "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null",
+ "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
+ "echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
} else {
$commands = $networks->map(function ($network) {
+ $safe = escapeshellarg($network);
return [
- "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null",
- "docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
- "echo 'Successfully connected coolify-proxy to $network network.'",
+ "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
+ "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
+ "echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
}
@@ -141,16 +143,18 @@ function ensureProxyNetworksExist(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
+ $safe = escapeshellarg($network);
return [
- "echo 'Ensuring network $network exists...'",
- "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
+ "echo 'Ensuring network {$safe} exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
];
});
} else {
$commands = $networks->map(function ($network) {
+ $safe = escapeshellarg($network);
return [
- "echo 'Ensuring network $network exists...'",
- "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
+ "echo 'Ensuring network {$safe} exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
];
});
}
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index f819df380..2544719fc 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -1,9 +1,10 @@
teams->pluck('id');
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
- throw new \Exception('User is not part of the team that owns this server');
+ throw new Exception('User is not part of the team that owns this server');
}
}
SshMultiplexingHelper::ensureMultiplexedConnection($server);
- return resolve(PrepareCoolifyTask::class, [
- 'remoteProcessArgs' => new CoolifyTaskArgs(
- server_uuid: $server->uuid,
- command: $command_string,
- type: $type,
- type_uuid: $type_uuid,
- model: $model,
- ignore_errors: $ignore_errors,
- call_event_on_finish: $callEventOnFinish,
- call_event_data: $callEventData,
- ),
- ])();
+ $properties = [
+ 'server_uuid' => $server->uuid,
+ 'command' => $command_string,
+ 'type' => $type,
+ 'type_uuid' => $type_uuid,
+ 'status' => ProcessStatus::QUEUED->value,
+ 'team_id' => $server->team_id,
+ ];
+
+ $activityLog = activity()
+ ->withProperties($properties)
+ ->event($type);
+
+ if ($model) {
+ $activityLog->performedOn($model);
+ }
+
+ $activity = $activityLog->log('[]');
+
+ dispatch(new CoolifyTask(
+ activity: $activity,
+ ignore_errors: $ignore_errors,
+ call_event_on_finish: $callEventOnFinish,
+ call_event_data: $callEventData,
+ ));
+
+ $activity->refresh();
+
+ return $activity;
}
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{
- return \App\Helpers\SshRetryHandler::retry(
+ return SshRetryHandler::retry(
function () use ($source, $dest, $server) {
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
@@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
}
$command_string = implode("\n", $command);
- return \App\Helpers\SshRetryHandler::retry(
+ return SshRetryHandler::retry(
function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(30)->run($sshCommand);
@@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
$command_string = implode("\n", $command);
$effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
- return \App\Helpers\SshRetryHandler::retry(
+ return SshRetryHandler::retry(
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
$process = Process::timeout($effectiveTimeout)->run($sshCommand);
@@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
if ($ignored) {
// TODO: Create new exception and disable in sentry
- throw new \RuntimeException($errorMessage, $exitCode);
+ throw new RuntimeException($errorMessage, $exitCode);
}
- throw new \RuntimeException($errorMessage, $exitCode);
+ throw new RuntimeException($errorMessage, $exitCode);
}
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
@@ -194,7 +212,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true,
flags: JSON_THROW_ON_ERROR
);
- } catch (\JsonException $e) {
+ } catch (JsonException $e) {
// If JSON decoding fails, try to clean up the logs and retry
try {
// Ensure valid UTF-8 encoding
@@ -204,7 +222,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true,
flags: JSON_THROW_ON_ERROR
);
- } catch (\JsonException $e) {
+ } catch (JsonException $e) {
// If it still fails, return empty collection to prevent crashes
return collect([]);
}
@@ -353,7 +371,7 @@ function checkRequiredCommands(Server $server)
}
try {
instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server);
- } catch (\Throwable) {
+ } catch (Throwable) {
break;
}
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 84472a07e..cd773f6a9 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -16,6 +16,7 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -28,8 +29,10 @@ use App\Models\Team;
use App\Models\User;
use Carbon\CarbonImmutable;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Process\Pool;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
@@ -49,10 +52,14 @@ use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token\Builder;
+use Livewire\Component;
+use Nubs\RandomNameGenerator\All;
+use Nubs\RandomNameGenerator\Alliteration;
use phpseclib3\Crypt\EC;
use phpseclib3\Crypt\RSA;
use Poliander\Cron\CronExpression;
use PurplePixie\PhpDns\DNSQuery;
+use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
@@ -116,7 +123,7 @@ function sanitize_string(?string $input = null): ?string
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
* @return string The validated input (unchanged if valid)
*
- * @throws \Exception If dangerous characters are detected
+ * @throws Exception If dangerous characters are detected
*/
function validateShellSafePath(string $input, string $context = 'path'): string
{
@@ -138,7 +145,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
// Check for dangerous characters
foreach ($dangerousChars as $char => $description) {
if (str_contains($input, $char)) {
- throw new \Exception(
+ throw new Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
'Shell metacharacters are not allowed for security reasons.'
);
@@ -160,7 +167,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
* @param string $input The databases_to_backup string
* @return string The validated input
*
- * @throws \Exception If any component contains dangerous characters
+ * @throws Exception If any component contains dangerous characters
*/
function validateDatabasesBackupInput(string $input): string
{
@@ -211,7 +218,7 @@ function validateDatabasesBackupInput(string $input): string
* @param string $context Descriptive name for error messages
* @return string The validated input (trimmed)
*
- * @throws \Exception If the input contains disallowed characters
+ * @throws Exception If the input contains disallowed characters
*/
function validateGitRef(string $input, string $context = 'git ref'): string
{
@@ -223,12 +230,12 @@ function validateGitRef(string $input, string $context = 'git ref'): string
// 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.");
+ 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.");
+ throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
}
return $input;
@@ -282,7 +289,7 @@ function refreshSession(?Team $team = null): void
});
session(['currentTeam' => $team]);
}
-function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null)
+function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null)
{
if ($error instanceof TooManyRequestsException) {
if (isset($livewire)) {
@@ -299,7 +306,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
return 'Duplicate entry found. Please use a different name.';
}
- if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
+ if ($error instanceof ModelNotFoundException) {
abort(404);
}
@@ -329,7 +336,7 @@ function get_latest_sentinel_version(): string
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
- } catch (\Throwable) {
+ } catch (Throwable) {
return '0.0.0';
}
}
@@ -339,7 +346,7 @@ function get_latest_version_of_coolify(): string
$versions = get_versions_data();
return data_get($versions, 'coolify.v4.version', '0.0.0');
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
return '0.0.0';
}
@@ -347,9 +354,9 @@ function get_latest_version_of_coolify(): string
function generate_random_name(?string $cuid = null): string
{
- $generator = new \Nubs\RandomNameGenerator\All(
+ $generator = new All(
[
- new \Nubs\RandomNameGenerator\Alliteration,
+ new Alliteration,
]
);
if (is_null($cuid)) {
@@ -448,7 +455,7 @@ function getFqdnWithoutPort(string $fqdn)
$path = $url->getPath();
return "$scheme://$host$path";
- } catch (\Throwable) {
+ } catch (Throwable) {
return $fqdn;
}
}
@@ -478,13 +485,13 @@ function base_url(bool $withPort = true): string
}
if ($settings->public_ipv6) {
if ($withPort) {
- return "http://$settings->public_ipv6:$port";
+ return "http://[$settings->public_ipv6]:$port";
}
- return "http://$settings->public_ipv6";
+ return "http://[$settings->public_ipv6]";
}
- return url('/');
+ return config('app.url');
}
function isSubscribed()
@@ -537,21 +544,21 @@ function validate_cron_expression($expression_to_validate): bool
* Even if the job runs minutes late, it still catches the missed cron window.
* Without a dedupKey, falls back to a simple isDue() check.
*/
-function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
+function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool
{
- $cron = new \Cron\CronExpression($frequency);
- $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
+ $cron = new Cron\CronExpression($frequency);
+ $executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone);
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
- $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+ $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
$shouldFire = $lastDispatched === null
? $cron->isDue($executionTime)
- : $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
+ : $previousDue->gt(Carbon::parse($lastDispatched));
// Always write: seeds on first miss, refreshes on dispatch.
// 30-day static TTL covers all intervals; orphan keys self-clean.
@@ -932,7 +939,7 @@ function get_service_templates(bool $force = false): Collection
$services = $response->json();
return collect($services);
- } catch (\Throwable) {
+ } catch (Throwable) {
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
return collect(json_decode($services))->sortKeys();
@@ -955,7 +962,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
// ServiceDatabase has a different relationship path: service->environment->project->team_id
- if ($resource instanceof \App\Models\ServiceDatabase) {
+ if ($resource instanceof ServiceDatabase) {
if ($resource->service?->environment?->project?->team_id === $teamId) {
return $resource;
}
@@ -1081,7 +1088,7 @@ function generateGitManualWebhook($resource, $type)
if ($resource->source_id !== 0 && ! is_null($resource->source_id)) {
return null;
}
- if ($resource->getMorphClass() === \App\Models\Application::class) {
+ if ($resource->getMorphClass() === Application::class) {
$baseUrl = base_url();
return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual";
@@ -1102,11 +1109,11 @@ function sanitizeLogsForExport(string $text): string
function getTopLevelNetworks(Service|Application $resource)
{
- if ($resource->getMorphClass() === \App\Models\Service::class) {
+ if ($resource->getMorphClass() === Service::class) {
if ($resource->docker_compose_raw) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
$resource->uuid => [
@@ -1169,10 +1176,10 @@ function getTopLevelNetworks(Service|Application $resource)
return $topLevelNetworks->keys();
}
- } elseif ($resource->getMorphClass() === \App\Models\Application::class) {
+ } elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
$resource->uuid => [
@@ -1479,7 +1486,7 @@ function validateDNSEntry(string $fqdn, Server $server)
$ip = $server->ip;
}
$found_matching_ip = false;
- $type = \PurplePixie\PhpDns\DNSTypes::NAME_A;
+ $type = DNSTypes::NAME_A;
foreach ($dns_servers as $dns_server) {
try {
$query = new DNSQuery($dns_server);
@@ -1500,7 +1507,7 @@ function validateDNSEntry(string $fqdn, Server $server)
}
}
}
- } catch (\Exception) {
+ } catch (Exception) {
}
}
@@ -1682,7 +1689,7 @@ function get_public_ips()
}
InstanceSettings::get()->update(['public_ipv4' => $ipv4]);
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
echo "Error: {$e->getMessage()}\n";
}
try {
@@ -1697,7 +1704,7 @@ function get_public_ips()
}
InstanceSettings::get()->update(['public_ipv6' => $ipv6]);
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
echo "Error: {$e->getMessage()}\n";
}
}
@@ -1795,15 +1802,15 @@ function customApiValidator(Collection|array $item, array $rules)
}
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
{
- if ($resource->getMorphClass() === \App\Models\Service::class) {
+ if ($resource->getMorphClass() === 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) {
- throw new \RuntimeException($e->getMessage());
+ } catch (Exception $e) {
+ throw new RuntimeException($e->getMessage());
}
$allServices = get_service_templates();
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
@@ -2567,10 +2574,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} else {
return collect([]);
}
- } elseif ($resource->getMorphClass() === \App\Models\Application::class) {
+ } elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
- } catch (\Exception) {
+ } catch (Exception) {
return;
}
$server = $resource->destination->server;
@@ -3332,7 +3339,7 @@ function isAssociativeArray($array)
}
if (! is_array($array)) {
- throw new \InvalidArgumentException('Input must be an array or a Collection.');
+ throw new InvalidArgumentException('Input must be an array or a Collection.');
}
if ($array === []) {
@@ -3448,7 +3455,7 @@ function wireNavigate(): string
// Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
- } catch (\Exception $e) {
+ } catch (Exception $e) {
return 'wire:navigate.hover';
}
}
@@ -3457,13 +3464,13 @@ function wireNavigate(): string
* Redirect to a named route with SPA navigation support.
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
*/
-function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed
+function redirectRoute(Component $component, string $name, array $parameters = []): mixed
{
$navigate = true;
try {
$navigate = instanceSettings()->is_wire_navigate_enabled ?? true;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$navigate = true;
}
@@ -3505,7 +3512,7 @@ function loadConfigFromGit(string $repository, string $branch, string $base_dire
]);
try {
return instant_remote_process($commands, $server);
- } catch (\Exception) {
+ } catch (Exception) {
// continue
}
}
@@ -3636,8 +3643,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
// If this happens, the user may have provided an HTTP URL when they needed an SSH one
// Let's try and fix that for known Git providers
switch ($source->getMorphClass()) {
- case \App\Models\GithubApp::class:
- case \App\Models\GitlabApp::class:
+ case GithubApp::class:
+ case GitlabApp::class:
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
$providerInfo['port'] = $source->custom_port;
$providerInfo['user'] = $source->custom_user;
@@ -3915,10 +3922,10 @@ function shouldSkipPasswordConfirmation(): bool
* - User has no password (OAuth users)
*
* @param mixed $password The password to verify (may be array if skipped by frontend)
- * @param \Livewire\Component|null $component Optional Livewire component to add errors to
+ * @param Component|null $component Optional Livewire component to add errors to
* @return bool True if verification passed (or skipped), false if password is incorrect
*/
-function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
+function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool
{
// Skip if password confirmation should be skipped
if (shouldSkipPasswordConfirmation()) {
@@ -3941,17 +3948,17 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
* 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
+ * @return Collection Collection of arrays with: key, value, comment, service_name
*/
-function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
+function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection
{
if (blank($dockerComposeRaw)) {
return collect([]);
}
try {
- $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
- } catch (\Exception $e) {
+ $yaml = Yaml::parse($dockerComposeRaw);
+ } catch (Exception $e) {
// Malformed YAML - return empty collection
return collect([]);
}
@@ -4100,7 +4107,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
if (is_null($id)) {
continue;
}
- $found = \App\Models\SharedEnvironmentVariable::where('type', $type)
+ $found = SharedEnvironmentVariable::where('type', $type)
->where('key', $variable)
->where('team_id', $resource->team()->id)
->where("{$type}_id", $id)
diff --git a/composer.lock b/composer.lock
index 91900aa95..3884eac06 100644
--- a/composer.lock
+++ b/composer.lock
@@ -62,16 +62,17 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.373.9",
+ "version": "3.374.2",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "a73e12fe5d010f3c6cda2f6f020b5a475444487d"
+ "reference": "67b6b6210af47319c74c5666388d71bc1bc58276"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a73e12fe5d010f3c6cda2f6f020b5a475444487d",
- "reference": "a73e12fe5d010f3c6cda2f6f020b5a475444487d",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67b6b6210af47319c74c5666388d71bc1bc58276",
+ "reference": "67b6b6210af47319c74c5666388d71bc1bc58276",
+
"shasum": ""
},
"require": {
@@ -153,9 +154,10 @@
"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.373.9"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.374.2"
},
- "time": "2026-03-24T18:06:07+00:00"
+ "time": "2026-03-27T18:05:55+00:00"
+
},
{
"name": "bacon/bacon-qr-code",
@@ -17311,5 +17313,5 @@
"php": "^8.4"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/config/purify.php b/config/purify.php
index 66dbbb568..a5dcabb92 100644
--- a/config/purify.php
+++ b/config/purify.php
@@ -49,6 +49,17 @@ return [
'AutoFormat.RemoveEmpty' => false,
],
+ 'validation_logs' => [
+ 'Core.Encoding' => 'utf-8',
+ 'HTML.Doctype' => 'HTML 4.01 Transitional',
+ 'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]',
+ 'HTML.ForbiddenElements' => '',
+ 'CSS.AllowedProperties' => '',
+ 'AutoFormat.AutoParagraph' => false,
+ 'AutoFormat.RemoveEmpty' => false,
+ 'Attr.AllowedFrameTargets' => ['_blank'],
+ ],
+
],
/*
diff --git a/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php b/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php
new file mode 100644
index 000000000..66d585069
--- /dev/null
+++ b/database/migrations/2025_12_24_095507_add_server_to_shared_environment_variables_table.php
@@ -0,0 +1,47 @@
+foreignId('server_id')->nullable()->constrained()->onDelete('cascade');
+ // NULL != NULL in PostgreSQL unique indexes, so this only enforces uniqueness
+ // for server-scoped rows (where server_id is non-null). Other scopes are covered
+ // by existing unique constraints on ['key', 'project_id', 'team_id'] and ['key', 'environment_id', 'team_id'].
+ $table->unique(['key', 'server_id', 'team_id']);
+ });
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ DB::transaction(function () {
+ Schema::table('shared_environment_variables', function (Blueprint $table) {
+ $table->dropUnique(['key', 'server_id', 'team_id']);
+ $table->dropForeign(['server_id']);
+ $table->dropColumn('server_id');
+ });
+ if (DB::getDriverName() !== 'sqlite') {
+ DB::statement('ALTER TABLE shared_environment_variables DROP CONSTRAINT IF EXISTS shared_environment_variables_type_check');
+ DB::statement("ALTER TABLE shared_environment_variables ADD CONSTRAINT shared_environment_variables_type_check CHECK (type IN ('team', 'project', 'environment'))");
+ }
+ });
+ }
+};
diff --git a/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
new file mode 100644
index 000000000..c67987e67
--- /dev/null
+++ b/database/migrations/2025_12_24_133707_add_predefined_server_variables_to_existing_servers.php
@@ -0,0 +1,56 @@
+chunk(100, function ($servers) {
+ foreach ($servers as $server) {
+ $existingKeys = SharedEnvironmentVariable::where('type', 'server')
+ ->where('server_id', $server->id)
+ ->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
+ ->pluck('key')
+ ->toArray();
+
+ if (! in_array('COOLIFY_SERVER_UUID', $existingKeys)) {
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'value' => $server->uuid,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'is_literal' => true,
+ ]);
+ }
+
+ if (! in_array('COOLIFY_SERVER_NAME', $existingKeys)) {
+ SharedEnvironmentVariable::create([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'value' => $server->name,
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ 'is_literal' => true,
+ ]);
+ }
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ SharedEnvironmentVariable::where('type', 'server')
+ ->whereIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])
+ ->delete();
+ }
+};
diff --git a/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php
new file mode 100644
index 000000000..a4a6988f2
--- /dev/null
+++ b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php
@@ -0,0 +1,39 @@
+chunkById(100, function ($clickhouses) {
+ foreach ($clickhouses as $clickhouse) {
+ $password = $clickhouse->clickhouse_admin_password;
+
+ if (empty($password)) {
+ continue;
+ }
+
+ // Skip if already encrypted (idempotent)
+ try {
+ Crypt::decryptString($password);
+
+ continue;
+ } catch (Exception) {
+ // Not encrypted yet โ encrypt it
+ }
+
+ DB::table('standalone_clickhouses')
+ ->where('id', $clickhouse->id)
+ ->update(['clickhouse_admin_password' => Crypt::encryptString($password)]);
+ }
+ });
+ } catch (Exception $e) {
+ echo 'Encrypting ClickHouse admin passwords failed.';
+ echo $e->getMessage();
+ }
+ }
+}
diff --git a/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php b/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php
new file mode 100644
index 000000000..2dafa2737
--- /dev/null
+++ b/database/migrations/2026_03_30_120000_add_docker_registry_image_tag_to_application_previews_and_deployment_queues.php
@@ -0,0 +1,30 @@
+string('docker_registry_image_tag')->nullable()->after('docker_compose_domains');
+ });
+
+ Schema::table('application_deployment_queues', function (Blueprint $table) {
+ $table->string('docker_registry_image_tag')->nullable()->after('pull_request_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('application_previews', function (Blueprint $table) {
+ $table->dropColumn('docker_registry_image_tag');
+ });
+
+ Schema::table('application_deployment_queues', function (Blueprint $table) {
+ $table->dropColumn('docker_registry_image_tag');
+ });
+ }
+};
diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php
index e3968a1c9..c4e93af63 100644
--- a/database/seeders/RootUserSeeder.php
+++ b/database/seeders/RootUserSeeder.php
@@ -45,12 +45,13 @@ class RootUserSeeder extends Seeder
}
try {
- User::create([
+ $user = (new User)->forceFill([
'id' => 0,
'name' => env('ROOT_USERNAME', 'Root User'),
'email' => env('ROOT_USER_EMAIL'),
'password' => Hash::make(env('ROOT_USER_PASSWORD')),
]);
+ $user->save();
echo "\n SUCCESS Root user created successfully.\n\n";
} catch (\Exception $e) {
echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n";
diff --git a/database/seeders/SharedEnvironmentVariableSeeder.php b/database/seeders/SharedEnvironmentVariableSeeder.php
index 54643fe3b..7a17fbd10 100644
--- a/database/seeders/SharedEnvironmentVariableSeeder.php
+++ b/database/seeders/SharedEnvironmentVariableSeeder.php
@@ -2,6 +2,7 @@
namespace Database\Seeders;
+use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use Illuminate\Database\Seeder;
@@ -32,5 +33,29 @@ class SharedEnvironmentVariableSeeder extends Seeder
'project_id' => 1,
'team_id' => 0,
]);
+
+ // Add predefined server variables to all existing servers
+ $servers = \App\Models\Server::all();
+ foreach ($servers as $server) {
+ SharedEnvironmentVariable::firstOrCreate([
+ 'key' => 'COOLIFY_SERVER_UUID',
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ ], [
+ 'value' => $server->uuid,
+ 'is_literal' => true,
+ ]);
+
+ SharedEnvironmentVariable::firstOrCreate([
+ 'key' => 'COOLIFY_SERVER_NAME',
+ 'type' => 'server',
+ 'server_id' => $server->id,
+ 'team_id' => $server->team_id,
+ ], [
+ 'value' => $server->name,
+ 'is_literal' => true,
+ ]);
+ }
}
}
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run
index ada19b3a3..dbc472d06 100644
--- a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run
+++ b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run
@@ -1,12 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:horizon
-}
+if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Horizon is disabled, sleeping."
+ exec sleep infinity
+fi
+echo " INFO Horizon is enabled, starting..."
+exec php artisan horizon
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
index 1166ccd08..ee46dba7e 100644
--- a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
+++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
@@ -1,12 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:nightwatch
-}
+if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
+ echo " INFO Nightwatch is enabled, starting..."
+ exec php artisan nightwatch:agent
+fi
+echo " INFO Nightwatch is disabled, sleeping."
+exec sleep infinity
diff --git a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run
index b81a44833..bfa44c7e3 100644
--- a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run
+++ b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run
@@ -1,13 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:scheduler
-}
-
+if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Scheduler is disabled, sleeping."
+ exec sleep infinity
+fi
+echo " INFO Scheduler is enabled, starting..."
+exec php artisan schedule:work
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run
index be6647607..dbc472d06 100644
--- a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run
+++ b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run
@@ -1,11 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:horizon
-}
+if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Horizon is disabled, sleeping."
+ exec sleep infinity
+fi
+
+echo " INFO Horizon is enabled, starting..."
+exec php artisan horizon
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
index 80d73eadb..ee46dba7e 100644
--- a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
+++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run
@@ -1,11 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:nightwatch
-}
+if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
+ echo " INFO Nightwatch is enabled, starting..."
+ exec php artisan nightwatch:agent
+fi
+
+echo " INFO Nightwatch is disabled, sleeping."
+exec sleep infinity
diff --git a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run
index a2ecb0a73..bfa44c7e3 100644
--- a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run
+++ b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run
@@ -1,10 +1,11 @@
-#!/command/execlineb -P
+#!/bin/sh
-# Use with-contenv to ensure environment variables are available
-with-contenv
cd /var/www/html
-foreground {
- php
- artisan
- start:scheduler
-}
+
+if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
+ echo " INFO Scheduler is disabled, sleeping."
+ exec sleep infinity
+fi
+
+echo " INFO Scheduler is enabled, starting..."
+exec php artisan schedule:work
diff --git a/openapi.json b/openapi.json
index aec5a2843..239068300 100644
--- a/openapi.json
+++ b/openapi.json
@@ -407,6 +407,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
@@ -852,6 +857,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
@@ -1297,6 +1307,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Preserve repository during deployment."
}
},
"type": "object"
@@ -2704,6 +2719,10 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "is_preserve_repository_enabled": {
+ "type": "boolean",
+ "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false."
}
},
"type": "object"
@@ -4312,6 +4331,11 @@
"database_backup_retention_max_storage_s3": {
"type": "integer",
"description": "Max storage (MB) for S3 backups"
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Backup job timeout in seconds (min: 60, max: 36000)",
+ "default": 3600
}
},
"type": "object"
@@ -4544,6 +4568,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -4873,6 +4901,11 @@
"database_backup_retention_max_storage_s3": {
"type": "integer",
"description": "Max storage of the backup in S3"
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Backup job timeout in seconds (min: 60, max: 36000)",
+ "default": 3600
}
},
"type": "object"
@@ -4989,6 +5022,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5117,6 +5154,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5241,6 +5282,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5369,6 +5414,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5497,6 +5546,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5637,6 +5690,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5777,6 +5834,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -5905,6 +5966,10 @@
"type": "integer",
"description": "Public port of the database"
},
+ "public_port_timeout": {
+ "type": "integer",
+ "description": "Public port timeout in seconds (default: 3600)"
+ },
"limits_memory": {
"type": "string",
"description": "Memory limit of the database"
@@ -7219,6 +7284,22 @@
"schema": {
"type": "integer"
}
+ },
+ {
+ "name": "pull_request_id",
+ "in": "query",
+ "description": "Preview deployment identifier. Alias of pr.",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "docker_tag",
+ "in": "query",
+ "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.",
+ "schema": {
+ "type": "string"
+ }
}
],
"responses": {
@@ -10380,6 +10461,26 @@
"none"
],
"description": "The proxy type."
+ },
+ "concurrent_builds": {
+ "type": "integer",
+ "description": "Number of concurrent builds."
+ },
+ "dynamic_timeout": {
+ "type": "integer",
+ "description": "Deployment timeout in seconds."
+ },
+ "deployment_queue_limit": {
+ "type": "integer",
+ "description": "Maximum number of queued deployments."
+ },
+ "server_disk_usage_notification_threshold": {
+ "type": "integer",
+ "description": "Server disk usage notification threshold (%)."
+ },
+ "server_disk_usage_check_frequency": {
+ "type": "string",
+ "description": "Cron expression for disk usage check frequency."
}
},
"type": "object"
@@ -12679,6 +12780,10 @@
"pull_request_id": {
"type": "integer"
},
+ "docker_registry_image_tag": {
+ "type": "string",
+ "nullable": true
+ },
"force_rebuild": {
"type": "boolean"
},
diff --git a/openapi.yaml b/openapi.yaml
index 93038ce80..5bf6059af 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -291,6 +291,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'201':
@@ -575,6 +579,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'201':
@@ -859,6 +867,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ default: false
+ description: 'Preserve repository during deployment.'
type: object
responses:
'201':
@@ -1741,6 +1753,9 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ is_preserve_repository_enabled:
+ type: boolean
+ description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'
type: object
responses:
'200':
@@ -2719,6 +2734,10 @@ paths:
database_backup_retention_max_storage_s3:
type: integer
description: 'Max storage (MB) for S3 backups'
+ timeout:
+ type: integer
+ description: 'Backup job timeout in seconds (min: 60, max: 36000)'
+ default: 3600
type: object
responses:
'201':
@@ -2873,6 +2892,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3107,6 +3129,10 @@ paths:
database_backup_retention_max_storage_s3:
type: integer
description: 'Max storage of the backup in S3'
+ timeout:
+ type: integer
+ description: 'Backup job timeout in seconds (min: 60, max: 36000)'
+ default: 3600
type: object
responses:
'200':
@@ -3189,6 +3215,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3281,6 +3310,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3370,6 +3402,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3462,6 +3497,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3554,6 +3592,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3655,6 +3696,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3756,6 +3800,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -3848,6 +3895,9 @@ paths:
public_port:
type: integer
description: 'Public port of the database'
+ public_port_timeout:
+ type: integer
+ description: 'Public port timeout in seconds (default: 3600)'
limits_memory:
type: string
description: 'Memory limit of the database'
@@ -4668,6 +4718,18 @@ paths:
description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.'
schema:
type: integer
+ -
+ name: pull_request_id
+ in: query
+ description: 'Preview deployment identifier. Alias of pr.'
+ schema:
+ type: integer
+ -
+ name: docker_tag
+ in: query
+ description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.'
+ schema:
+ type: string
responses:
'200':
description: "Get deployment(s) UUID's"
@@ -6615,6 +6677,21 @@ paths:
type: string
enum: [traefik, caddy, none]
description: 'The proxy type.'
+ concurrent_builds:
+ type: integer
+ description: 'Number of concurrent builds.'
+ dynamic_timeout:
+ type: integer
+ description: 'Deployment timeout in seconds.'
+ deployment_queue_limit:
+ type: integer
+ description: 'Maximum number of queued deployments.'
+ server_disk_usage_notification_threshold:
+ type: integer
+ description: 'Server disk usage notification threshold (%).'
+ server_disk_usage_check_frequency:
+ type: string
+ description: 'Cron expression for disk usage check frequency.'
type: object
responses:
'201':
@@ -8063,6 +8140,9 @@ components:
type: string
pull_request_id:
type: integer
+ docker_registry_image_tag:
+ type: string
+ nullable: true
force_rebuild:
type: boolean
commit:
diff --git a/package-lock.json b/package-lock.json
index 3c9753bb8..6959704a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2388,9 +2388,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2795,9 +2795,9 @@
}
},
"node_modules/vite-plugin-full-reload/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/public/svgs/electricsql.svg b/public/svgs/electricsql.svg
new file mode 100644
index 000000000..bbffe200a
--- /dev/null
+++ b/public/svgs/electricsql.svg
@@ -0,0 +1,4 @@
+
diff --git a/resources/css/app.css b/resources/css/app.css
index 3cfa03dae..936e0c713 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -14,7 +14,10 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
- --font-sans: Inter, sans-serif;
+ --font-sans: 'Geist Sans', Inter, sans-serif;
+ --font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ --font-geist-sans: 'Geist Sans', Inter, sans-serif;
+ --font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--color-base: #101010;
--color-warning: #fcd452;
@@ -96,7 +99,7 @@ body {
}
body {
- @apply min-h-screen text-sm antialiased scrollbar overflow-x-hidden;
+ @apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;
}
.coolify-monaco-editor {
diff --git a/resources/css/fonts.css b/resources/css/fonts.css
index c8c4448eb..e5c6a694d 100644
--- a/resources/css/fonts.css
+++ b/resources/css/fonts.css
@@ -70,3 +70,18 @@
src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2');
}
+@font-face {
+ font-display: swap;
+ font-family: 'Geist Mono';
+ font-style: normal;
+ font-weight: 100 900;
+ src: url('../fonts/geist-mono-variable.woff2') format('woff2');
+}
+
+@font-face {
+ font-display: swap;
+ font-family: 'Geist Sans';
+ font-style: normal;
+ font-weight: 100 900;
+ src: url('../fonts/geist-sans-variable.woff2') format('woff2');
+}
diff --git a/resources/fonts/geist-mono-variable.woff2 b/resources/fonts/geist-mono-variable.woff2
new file mode 100644
index 000000000..c8a7d8401
Binary files /dev/null and b/resources/fonts/geist-mono-variable.woff2 differ
diff --git a/resources/fonts/geist-sans-variable.woff2 b/resources/fonts/geist-sans-variable.woff2
new file mode 100644
index 000000000..7ebce69dc
Binary files /dev/null and b/resources/fonts/geist-sans-variable.woff2 differ
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index 3c52edfa0..aa5f37353 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -186,7 +186,7 @@ export function initializeTerminalComponent() {
this.term = new Terminal({
cols: 80,
rows: 30,
- fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"',
+ fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"',
cursorBlink: true,
rendererType: 'canvas',
convertEol: true,
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 2466a57f9..642bbcfb0 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -10,15 +10,22 @@
@endif
-
diff --git a/resources/views/emails/server-force-disabled.blade.php b/resources/views/emails/server-force-disabled.blade.php
index 805df3296..4ab46b5a0 100644
--- a/resources/views/emails/server-force-disabled.blade.php
+++ b/resources/views/emails/server-force-disabled.blade.php
@@ -1,5 +1,5 @@
Your server ({{ $name }}) disabled because it is not paid! All automations and integrations are stopped.
- Please update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).
+ Please update your subscription to enable the server again [here](https://app.coolify.io/subscription).
diff --git a/resources/views/invitation/accept.blade.php b/resources/views/invitation/accept.blade.php
new file mode 100644
index 000000000..7e4773866
--- /dev/null
+++ b/resources/views/invitation/accept.blade.php
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ Coolify
+
+
+
+
+
+
Team Invitation
+
+
+ You have been invited to join:
+
+
+ {{ $team->name }}
+
+
+
+ Role: {{ ucfirst($invitation->role) }}
+
+
+ @if ($alreadyMember)
+
+
You are already a member of this team.
+
+ @endif
+
+
+
+
+
+
+
+
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 2b4ca6054..33968ee32 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -203,30 +203,6 @@
let checkHealthInterval = null;
let checkIfIamDeadInterval = null;
- function changePasswordFieldType(event) {
- let element = event.target
- for (let i = 0; i < 10; i++) {
- if (element.className === "relative") {
- break;
- }
- element = element.parentElement;
- }
- element = element.children[1];
- if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
- if (element.type === 'password') {
- element.type = 'text';
- if (element.disabled) return;
- element.classList.add('truncate');
- this.type = 'text';
- } else {
- element.type = 'password';
- if (element.disabled) return;
- element.classList.remove('truncate');
- this.type = 'password';
- }
- }
- }
-
function copyToClipboard(text) {
navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.');
}
@@ -326,4 +302,4 @@